From 5cb48c5efa9aab1b86e45aeb7547a4bd731af92b Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Sat, 15 Aug 2026 13:23:40 +0300 Subject: [PATCH 01/24] chore(ci): build with Go 1.26.6 to clear stdlib vulnerabilities --- .github/workflows/e2e.yaml | 2 +- .github/workflows/go.yml | 6 +++--- .github/workflows/goreleaser.yml | 2 +- CHANGELOG.md | 9 ++++++++- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 374a662..26cd50a 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v7 with: - go-version: "1.26.5" + go-version: "1.26.6" - name: Run converting run: make e2e diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 61eaa50..4e97611 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -17,7 +17,7 @@ jobs: matrix: go-version: - "1.21.x" - - "1.26.5" + - "1.26.6" steps: - uses: actions/checkout@v7 with: @@ -45,7 +45,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v7 with: - go-version: "1.26.5" + go-version: "1.26.6" - name: Verify modules run: go mod verify @@ -75,5 +75,5 @@ jobs: - id: govulncheck uses: golang/govulncheck-action@v1 with: - go-version-input: "1.26.5" + go-version-input: "1.26.6" go-package: ./... diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 0b43d6a..af49a59 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v7 with: - go-version: "1.26.5" + go-version: "1.26.6" - name: Verify tag matches the app version run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eea3c8..c8759a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ CLI behaviour, not about the output format. dumped configuration. **If you ran `gh-md-toc --debug` in CI or any environment with shared logs, rotate that token.** The debug output now only reports whether a token was configured. ([#60](https://github.com/ekalinin/github-markdown-toc.go/pull/60)) +- CI and releases now build with Go 1.26.6. Go 1.26.5 carried four standard-library + vulnerabilities that `govulncheck` reports as reachable from this code: + [GO-2026-6218](https://pkg.go.dev/vuln/GO-2026-6218) (`net/url`), + [GO-2026-6090](https://pkg.go.dev/vuln/GO-2026-6090) (`crypto/tls`), + [GO-2026-5972](https://pkg.go.dev/vuln/GO-2026-5972) (`encoding/asn1`) and + [GO-2026-5026](https://pkg.go.dev/vuln/GO-2026-5026) (`net/http`). Binaries you built + yourself with Go 1.26.5 or earlier should be rebuilt. ### Changed @@ -41,7 +48,7 @@ CLI behaviour, not about the output format. - Error messages name the document that failed and the operation that failed on it. ([#61](https://github.com/ekalinin/github-markdown-toc.go/pull/61)) - Building from source now requires Go 1.21 or newer, matching the `log/slog` usage that - was already in the code. Releases are built with Go 1.26.5. + was already in the code. Releases are built with Go 1.26.6. ([#58](https://github.com/ekalinin/github-markdown-toc.go/pull/58)) ### Fixed From d5d2a9464c8e256a62678e64f1af5cfa48ab1cb4 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Thu, 13 Aug 2026 15:38:46 +0300 Subject: [PATCH 02/24] refactor(toc): pass the document path into Render and Grab --- .../toc_rendering_integration_test.go | 4 +- internal/core/toc/generator.go | 6 ++- internal/core/toc/generator_test.go | 26 ++++++++-- internal/core/toc/renderer.go | 7 +-- internal/core/toc/renderer_test.go | 52 +++++++++++++++++-- internal/core/usecase/localmd/localmd.go | 4 +- internal/core/usecase/localmd/localmd_test.go | 2 +- .../core/usecase/remotehtml/remotehtml.go | 4 +- .../usecase/remotehtml/remotehtml_test.go | 2 +- .../core/usecase/remotemd/remotemd_test.go | 2 +- 10 files changed, 88 insertions(+), 21 deletions(-) diff --git a/internal/adapters/toc_rendering_integration_test.go b/internal/adapters/toc_rendering_integration_test.go index d1ab2e5..f65c3af 100644 --- a/internal/adapters/toc_rendering_integration_test.go +++ b/internal/adapters/toc_rendering_integration_test.go @@ -23,11 +23,11 @@ func TestJSONAndRegexpExtractorsRenderSameTOC(t *testing.T) { {"level":2,"text":"Mandatory elements","anchor":"mandatory-elements"}, {"level":3,"text":"The command plug_list_versions","anchor":"plug_list_versions"} ]}}}}` - jsonTOC, err := jsonGenerator.Grab(context.Background(), jsonInput) + jsonTOC, err := jsonGenerator.Grab(context.Background(), "", jsonInput) if err != nil { t.Fatal(err) } - regexpTOC, err := regexpGenerator.Grab(context.Background(), htmlHeadingsV2024) + regexpTOC, err := regexpGenerator.Grab(context.Background(), "", htmlHeadingsV2024) if err != nil { t.Fatal(err) } diff --git a/internal/core/toc/generator.go b/internal/core/toc/generator.go index f5d7fb8..530ef35 100644 --- a/internal/core/toc/generator.go +++ b/internal/core/toc/generator.go @@ -25,13 +25,15 @@ func NewGenerator(extractor HeadingExtractor, renderer *Renderer) *Generator { } } -func (g *Generator) Grab(ctx context.Context, input string) (*entity.Toc, error) { +// Grab extracts headings from input and renders them as a TOC for the document at +// path. The path is only used when the renderer is configured for absolute paths. +func (g *Generator) Grab(ctx context.Context, path, input string) (*entity.Toc, error) { headings, err := g.extractor.Extract(ctx, input) if err != nil { return nil, fmt.Errorf("extract headings: %w", err) } - result, err := g.renderer.Render(ctx, headings) + result, err := g.renderer.Render(ctx, path, headings) if err != nil { return nil, fmt.Errorf("render TOC: %w", err) } diff --git a/internal/core/toc/generator_test.go b/internal/core/toc/generator_test.go index d791701..b8116b7 100644 --- a/internal/core/toc/generator_test.go +++ b/internal/core/toc/generator_test.go @@ -26,7 +26,7 @@ func TestGeneratorRendersExtractedHeadings(t *testing.T) { }}} generator := NewGenerator(extractor, NewRenderer(DefaultConfig())) - got, err := generator.Grab(context.Background(), "input") + got, err := generator.Grab(context.Background(), "", "input") if err != nil { t.Fatal(err) } @@ -40,7 +40,7 @@ func TestGeneratorPropagatesExtractorError(t *testing.T) { extractorErr := errors.New("extract failed") generator := NewGenerator(extractorStub{err: extractorErr}, NewRenderer(DefaultConfig())) - _, err := generator.Grab(context.Background(), "input") + _, err := generator.Grab(context.Background(), "", "input") if !errors.Is(err, extractorErr) { t.Fatalf("got error %v, want extractor error", err) } @@ -54,8 +54,28 @@ func TestGeneratorPropagatesRendererCancellation(t *testing.T) { NewRenderer(DefaultConfig()), ) - _, err := generator.Grab(ctx, "input") + _, err := generator.Grab(ctx, "", "input") if !errors.Is(err, context.Canceled) { t.Fatalf("got error %v, want context cancellation", err) } } + +func TestGeneratorPassesPathToRenderer(t *testing.T) { + extractor := extractorStub{headings: []entity.Heading{{ + Level: 1, + Text: "Title", + Anchor: "title", + }}} + cfg := DefaultConfig() + cfg.AbsolutePaths = true + generator := NewGenerator(extractor, NewRenderer(cfg)) + + got, err := generator.Grab(context.Background(), "docs/README.md", "input") + if err != nil { + t.Fatal(err) + } + want := entity.Toc{"* [Title](docs/README.md#title)"} + if got == nil || !slices.Equal(*got, want) { + t.Errorf("got TOC %v, want %v", got, want) + } +} diff --git a/internal/core/toc/renderer.go b/internal/core/toc/renderer.go index ca9ac2e..9d8fa54 100644 --- a/internal/core/toc/renderer.go +++ b/internal/core/toc/renderer.go @@ -9,7 +9,6 @@ import ( // Config controls how headings are filtered and rendered as a Markdown TOC. type Config struct { - Path string AbsolutePaths bool StartDepth int Depth int @@ -33,7 +32,9 @@ func NewRenderer(cfg Config) *Renderer { return &Renderer{cfg: cfg} } -func (r *Renderer) Render(ctx context.Context, headings []entity.Heading) (entity.Toc, error) { +// Render turns headings into a Markdown TOC. When AbsolutePaths is set, path is +// prefixed to every anchor, which is what multi-document mode needs. +func (r *Renderer) Render(ctx context.Context, path string, headings []entity.Heading) (entity.Toc, error) { if err := ctx.Err(); err != nil { return nil, err } @@ -68,7 +69,7 @@ func (r *Renderer) Render(ctx context.Context, headings []entity.Heading) (entit } link := "#" + heading.Anchor if r.cfg.AbsolutePaths { - link = r.cfg.Path + link + link = path + link } indent := strings.Repeat(" ", max(0, heading.Level-baseLevel)*max(0, r.cfg.Indent)) result = append(result, indent+"* ["+text+"]("+link+")") diff --git a/internal/core/toc/renderer_test.go b/internal/core/toc/renderer_test.go index 09bb702..0136265 100644 --- a/internal/core/toc/renderer_test.go +++ b/internal/core/toc/renderer_test.go @@ -19,6 +19,7 @@ func TestRendererRender(t *testing.T) { tests := []struct { name string cfg Config + path string headings []entity.Heading want entity.Toc }{ @@ -63,11 +64,11 @@ func TestRendererRender(t *testing.T) { { name: "absolute paths", cfg: Config{ - Path: "README.md", AbsolutePaths: true, Escape: true, Indent: 2, }, + path: "README.md", want: entity.Toc{ "* [Root\\.](README.md#root)", " * [Child\\_\\*](README.md#child_)", @@ -117,7 +118,7 @@ func TestRendererRender(t *testing.T) { if input == nil { input = headings } - got, err := NewRenderer(tt.cfg).Render(context.Background(), input) + got, err := NewRenderer(tt.cfg).Render(context.Background(), tt.path, input) if err != nil { t.Fatal(err) } @@ -129,7 +130,7 @@ func TestRendererRender(t *testing.T) { } func TestRendererReturnsEmptyTOC(t *testing.T) { - got, err := NewRenderer(DefaultConfig()).Render(context.Background(), nil) + got, err := NewRenderer(DefaultConfig()).Render(context.Background(), "", nil) if err != nil { t.Fatal(err) } @@ -141,8 +142,51 @@ func TestRendererReturnsEmptyTOC(t *testing.T) { func TestRendererReturnsContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err := NewRenderer(DefaultConfig()).Render(ctx, []entity.Heading{{Level: 1}}) + _, err := NewRenderer(DefaultConfig()).Render(ctx, "", []entity.Heading{{Level: 1}}) if !errors.Is(err, context.Canceled) { t.Fatalf("got error %v, want context cancellation", err) } } + +func TestRendererRenderPrefixesPath(t *testing.T) { + headings := []entity.Heading{ + {Level: 1, Text: "Root", Anchor: "root"}, + {Level: 2, Text: "Child", Anchor: "child"}, + } + tests := []struct { + name string + cfg Config + path string + want entity.Toc + }{ + { + name: "absolute paths on", + cfg: Config{AbsolutePaths: true, Escape: true, Indent: 2}, + path: "docs/README.md", + want: entity.Toc{ + "* [Root](docs/README.md#root)", + " * [Child](docs/README.md#child)", + }, + }, + { + name: "absolute paths off ignores the path", + cfg: Config{Escape: true, Indent: 2}, + path: "docs/README.md", + want: entity.Toc{ + "* [Root](#root)", + " * [Child](#child)", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewRenderer(tt.cfg).Render(context.Background(), tt.path, headings) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(got, tt.want) { + t.Errorf("got TOC %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/core/usecase/localmd/localmd.go b/internal/core/usecase/localmd/localmd.go index a08f1f7..ce0f1c9 100644 --- a/internal/core/usecase/localmd/localmd.go +++ b/internal/core/usecase/localmd/localmd.go @@ -21,7 +21,7 @@ type htmlConverter interface { } type tocGrabber interface { - Grab(context.Context, string) (*entity.Toc, error) + Grab(context.Context, string, string) (*entity.Toc, error) } type logger interface { @@ -86,7 +86,7 @@ func (uc *LocalMd) Do(ctx context.Context, file string) (entity.Toc, error) { } uc.log.Info("LocalMD: grabbing the TOC ...") - toc, err := uc.grabber.Grab(ctx, html) + toc, err := uc.grabber.Grab(ctx, file, html) if err != nil { uc.log.Info("LocalMD: failed to grab TOC: %s", err) return nil, fmt.Errorf("grab TOC from local Markdown %q: %w", file, err) diff --git a/internal/core/usecase/localmd/localmd_test.go b/internal/core/usecase/localmd/localmd_test.go index 10828aa..bb28168 100644 --- a/internal/core/usecase/localmd/localmd_test.go +++ b/internal/core/usecase/localmd/localmd_test.go @@ -42,7 +42,7 @@ type grabberStub struct { err error } -func (s grabberStub) Grab(context.Context, string) (*entity.Toc, error) { +func (s grabberStub) Grab(context.Context, string, string) (*entity.Toc, error) { return s.toc, s.err } diff --git a/internal/core/usecase/remotehtml/remotehtml.go b/internal/core/usecase/remotehtml/remotehtml.go index eb1fcd5..4f88ad1 100644 --- a/internal/core/usecase/remotehtml/remotehtml.go +++ b/internal/core/usecase/remotehtml/remotehtml.go @@ -16,7 +16,7 @@ type remoteGetter interface { } type tocGrabber interface { - Grab(context.Context, string) (*entity.Toc, error) + Grab(context.Context, string, string) (*entity.Toc, error) } type fileTemper interface { @@ -103,7 +103,7 @@ func (r *RemoteHTML) Do(ctx context.Context, url string) (entity.Toc, error) { } r.log.Info("RemoteHTML: grabbing the TOC ...") - toc, err := r.grabber.Grab(ctx, string(jsonBody)) + toc, err := r.grabber.Grab(ctx, url, string(jsonBody)) if err != nil { r.log.Info("RemoteHTML: failed to grab TOC", "err", err) return nil, fmt.Errorf("grab TOC from remote HTML %q: %w", url, err) diff --git a/internal/core/usecase/remotehtml/remotehtml_test.go b/internal/core/usecase/remotehtml/remotehtml_test.go index 4b11667..432d13b 100644 --- a/internal/core/usecase/remotehtml/remotehtml_test.go +++ b/internal/core/usecase/remotehtml/remotehtml_test.go @@ -43,7 +43,7 @@ type grabberStub struct { err error } -func (s grabberStub) Grab(context.Context, string) (*entity.Toc, error) { +func (s grabberStub) Grab(context.Context, string, string) (*entity.Toc, error) { return s.toc, s.err } diff --git a/internal/core/usecase/remotemd/remotemd_test.go b/internal/core/usecase/remotemd/remotemd_test.go index fcea8c1..719533f 100644 --- a/internal/core/usecase/remotemd/remotemd_test.go +++ b/internal/core/usecase/remotemd/remotemd_test.go @@ -68,7 +68,7 @@ type grabberStub struct { err error } -func (s grabberStub) Grab(context.Context, string) (*entity.Toc, error) { +func (s grabberStub) Grab(context.Context, string, string) (*entity.Toc, error) { return s.toc, s.err } From 3b57ef04bd1a792d6839598e7b60a740c4fd74d5 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Thu, 13 Aug 2026 17:40:32 +0300 Subject: [PATCH 03/24] fix(toc): scope absolute paths to multiple files and refresh architecture doc --- ARCHITECTURE.md | 3 +-- internal/app/new.go | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3fd6857..5cc4588 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -207,7 +207,6 @@ app.Config │ ├── GHUrl string │ └── GHVersion string └── TOC toc.Config - ├── Path string ├── AbsolutePaths bool ├── StartDepth int ├── Depth int @@ -309,7 +308,7 @@ The extractors only understand their external formats: - relative or absolute links; - Markdown list formatting. -Both extraction paths use the same `Renderer` instance, which prevents formatting behavior from diverging. +Both extraction paths use the same `Renderer` instance, which prevents formatting behavior from diverging. Because that instance is shared by the worker pool, the document path is passed into `Renderer.Render` and `Generator.Grab` as a call parameter rather than stored as renderer state. ## Concurrency and output ordering diff --git a/internal/app/new.go b/internal/app/new.go index a6c2b12..78bc782 100644 --- a/internal/app/new.go +++ b/internal/app/new.go @@ -51,7 +51,8 @@ func New(cfg Config) (*App, error) { } jsonExtractor := adapters.NewJSONExtractor() rendererCfg := cfg.TOC - rendererCfg.AbsolutePaths = len(cfg.Files) > 0 + // bash gh-md-toc drops the path prefix only when a single document is requested. + rendererCfg.AbsolutePaths = len(cfg.Files) > 1 renderer := coretoc.NewRenderer(rendererCfg) grabberRe := coretoc.NewGenerator(regexpExtractor, renderer) grabberJSON := coretoc.NewGenerator(jsonExtractor, renderer) From 6fcba77614b94f9c657818fdcc9071de1177e20c Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Thu, 13 Aug 2026 18:01:56 +0300 Subject: [PATCH 04/24] docs(architecture): describe the multi-file AbsolutePaths condition --- ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5cc4588..61d6dc5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -214,7 +214,7 @@ app.Config └── Indent int ``` -`cmd/gh-md-toc` maps flags and environment variables into this structure. `app.New` derives `TOC.AbsolutePaths` from whether the CLI received explicit file arguments. +`cmd/gh-md-toc` maps flags and environment variables into this structure. `app.New` derives `TOC.AbsolutePaths` from whether the CLI received multiple file arguments, matching bash `gh-md-toc`, which drops the prefix when a single document is requested. Only the settings required at runtime are passed further: From bb5faa2aa2e36be77999a2d6639a238474fd1c7e Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Thu, 13 Aug 2026 19:05:33 +0300 Subject: [PATCH 05/24] fix(toc): render remote Markdown links against the source URL --- ARCHITECTURE.md | 4 +- internal/core/usecase/localmd/localmd.go | 9 +++- internal/core/usecase/localmd/localmd_test.go | 38 +++++++++++---- internal/core/usecase/remotemd/remotemd.go | 4 +- .../core/usecase/remotemd/remotemd_test.go | 47 ++++++++++++++----- 5 files changed, 77 insertions(+), 25 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 61d6dc5..96311f4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -262,12 +262,12 @@ Controller -> RemoteGetter.Get -> FileTemper.CreateTemp -> write downloaded Markdown - -> LocalMd.Do + -> LocalMd.DoAs -> remove temporary file -> entity.Toc ``` -`RemoteMd` reuses the complete local Markdown workflow after downloading the document. It validates the response media type as `text/plain` before creating the temporary file. +`RemoteMd` reuses the complete local Markdown workflow after downloading the document. It validates the response media type as `text/plain` before creating the temporary file. It calls `LocalMd.DoAs` with the temporary file path and the original URL as the display path, so rendered links point at the source document instead of the temporary file. ### GitHub document page diff --git a/internal/core/usecase/localmd/localmd.go b/internal/core/usecase/localmd/localmd.go index ce0f1c9..99b5dcf 100644 --- a/internal/core/usecase/localmd/localmd.go +++ b/internal/core/usecase/localmd/localmd.go @@ -53,7 +53,14 @@ func New(debug bool, checker fileChecker, writer fileWriter, } } +// Do builds the TOC for file, linking anchors relative to that same file. func (uc *LocalMd) Do(ctx context.Context, file string) (entity.Toc, error) { + return uc.DoAs(ctx, file, file) +} + +// DoAs builds the TOC for file but renders links against displayPath. They differ +// when the content was downloaded or trimmed into a temporary file. +func (uc *LocalMd) DoAs(ctx context.Context, file, displayPath string) (entity.Toc, error) { if err := ctx.Err(); err != nil { return nil, fmt.Errorf("process local Markdown %q: %w", file, err) } @@ -86,7 +93,7 @@ func (uc *LocalMd) Do(ctx context.Context, file string) (entity.Toc, error) { } uc.log.Info("LocalMD: grabbing the TOC ...") - toc, err := uc.grabber.Grab(ctx, file, html) + toc, err := uc.grabber.Grab(ctx, displayPath, html) if err != nil { uc.log.Info("LocalMD: failed to grab TOC: %s", err) return nil, fmt.Errorf("grab TOC from local Markdown %q: %w", file, err) diff --git a/internal/core/usecase/localmd/localmd_test.go b/internal/core/usecase/localmd/localmd_test.go index bb28168..b93c2c3 100644 --- a/internal/core/usecase/localmd/localmd_test.go +++ b/internal/core/usecase/localmd/localmd_test.go @@ -4,6 +4,8 @@ import ( "context" "errors" "io/fs" + "os" + "path/filepath" "slices" "strings" "testing" @@ -38,11 +40,13 @@ func (s converterStub) Convert(context.Context, string) (string, error) { } type grabberStub struct { - toc *entity.Toc - err error + toc *entity.Toc + err error + gotPath string } -func (s grabberStub) Grab(context.Context, string, string) (*entity.Toc, error) { +func (s *grabberStub) Grab(_ context.Context, path, _ string) (*entity.Toc, error) { + s.gotPath = path return s.toc, s.err } @@ -57,7 +61,7 @@ func TestDoReturnsTOC(t *testing.T) { checkerStub{exists: true}, writerStub{}, converterStub{html: "

Title

"}, - grabberStub{toc: &want}, + &grabberStub{toc: &want}, loggerStub{}, ) @@ -77,7 +81,7 @@ func TestDoAcceptsEmptyTOC(t *testing.T) { checkerStub{exists: true}, writerStub{}, converterStub{}, - grabberStub{toc: &empty}, + &grabberStub{toc: &empty}, loggerStub{}, ) @@ -131,7 +135,7 @@ func TestDoPropagatesDependencyErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - uc := New(tt.debug, tt.checker, tt.writer, tt.converter, tt.grabber, loggerStub{}) + uc := New(tt.debug, tt.checker, tt.writer, tt.converter, &tt.grabber, loggerStub{}) _, err := uc.Do(context.Background(), "broken.md") if !errors.Is(err, dependencyErr) { t.Fatalf("got error %v, want dependency error", err) @@ -152,7 +156,7 @@ func TestDoReturnsErrorForMissingFile(t *testing.T) { checkerStub{}, writerStub{}, converterStub{}, - grabberStub{}, + &grabberStub{}, loggerStub{}, ) @@ -174,7 +178,7 @@ func TestDoReturnsContextCancellation(t *testing.T) { checkerStub{exists: true}, writerStub{}, converterStub{}, - grabberStub{}, + &grabberStub{}, loggerStub{}, ) @@ -183,3 +187,21 @@ func TestDoReturnsContextCancellation(t *testing.T) { t.Fatalf("got error %v, want context cancellation", err) } } + +func TestLocalMdDoAsUsesDisplayPath(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "source.md") + if err := os.WriteFile(file, []byte("# Title\n"), 0644); err != nil { + t.Fatal(err) + } + + grabber := &grabberStub{toc: &entity.Toc{"* [Title](#title)"}} + uc := New(false, checkerStub{exists: true}, writerStub{}, converterStub{html: "

Title

"}, grabber, loggerStub{}) + + if _, err := uc.DoAs(context.Background(), file, "https://example.com/README.md"); err != nil { + t.Fatal(err) + } + if grabber.gotPath != "https://example.com/README.md" { + t.Errorf("got grabber path %q, want the display path", grabber.gotPath) + } +} diff --git a/internal/core/usecase/remotemd/remotemd.go b/internal/core/usecase/remotemd/remotemd.go index 4382b18..f664eec 100644 --- a/internal/core/usecase/remotemd/remotemd.go +++ b/internal/core/usecase/remotemd/remotemd.go @@ -16,7 +16,7 @@ type remoteGetter interface { } type markdownProcessor interface { - Do(context.Context, string) (entity.Toc, error) + DoAs(context.Context, string, string) (entity.Toc, error) } type fileTemper interface { @@ -103,7 +103,7 @@ func (r *RemoteMd) Do(ctx context.Context, url string) (toc entity.Toc, err erro } }() - toc, err = r.ucLocalMD.Do(ctx, filename) + toc, err = r.ucLocalMD.DoAs(ctx, filename, url) if err != nil { return nil, fmt.Errorf("process remote Markdown %q: %w", url, err) } diff --git a/internal/core/usecase/remotemd/remotemd_test.go b/internal/core/usecase/remotemd/remotemd_test.go index 719533f..3f91682 100644 --- a/internal/core/usecase/remotemd/remotemd_test.go +++ b/internal/core/usecase/remotemd/remotemd_test.go @@ -64,11 +64,13 @@ func (s converterStub) Convert(context.Context, string) (string, error) { } type grabberStub struct { - toc *entity.Toc - err error + toc *entity.Toc + err error + gotPath string } -func (s grabberStub) Grab(context.Context, string, string) (*entity.Toc, error) { +func (s *grabberStub) Grab(_ context.Context, path, _ string) (*entity.Toc, error) { + s.gotPath = path return s.toc, s.err } @@ -108,7 +110,7 @@ func newUseCase( temper temperStub, writer writerStub, converter converterStub, - grabber grabberStub, + grabber *grabberStub, ) *RemoteMd { t.Helper() local := localmd.New( @@ -131,7 +133,7 @@ func TestDoReturnsTOC(t *testing.T) { temper, writerStub{}, converterStub{}, - grabberStub{toc: &want}, + &grabberStub{toc: &want}, ) got, err := uc.Do(context.Background(), "https://example.com/README.md") @@ -154,7 +156,7 @@ func TestDoPropagatesDownloadError(t *testing.T) { createTemper(t), writerStub{}, converterStub{}, - grabberStub{}, + &grabberStub{}, ) const documentURL = "https://example.com/README.md" @@ -179,7 +181,7 @@ func TestDoPropagatesTemporaryFileError(t *testing.T) { }, writerStub{}, converterStub{}, - grabberStub{}, + &grabberStub{}, ) _, err := uc.Do(context.Background(), "https://example.com/README.md") @@ -205,7 +207,7 @@ func TestDoCleansUpAfterTemporaryFileWriteError(t *testing.T) { temper, writerStub{}, converterStub{}, - grabberStub{}, + &grabberStub{}, ) _, err := uc.Do(context.Background(), "https://example.com/README.md") @@ -226,7 +228,7 @@ func TestDoKeepsRemotePathForLocalProcessingError(t *testing.T) { temper, writerStub{}, converterStub{err: dependencyErr}, - grabberStub{}, + &grabberStub{}, ) const documentURL = "https://example.com/README.md" @@ -249,7 +251,7 @@ func TestDoRejectsUnexpectedContentType(t *testing.T) { createTemper(t), writerStub{}, converterStub{}, - grabberStub{}, + &grabberStub{}, ) _, err := uc.Do(context.Background(), "https://example.com/README.md") @@ -265,7 +267,7 @@ func TestDoRejectsMalformedContentType(t *testing.T) { createTemper(t), writerStub{}, converterStub{}, - grabberStub{}, + &grabberStub{}, ) _, err := uc.Do(context.Background(), "https://example.com/README.md") @@ -287,7 +289,7 @@ func TestDoReturnsTemporaryFileRemovalError(t *testing.T) { temper, writerStub{}, converterStub{}, - grabberStub{toc: &want}, + &grabberStub{toc: &want}, ) _, err := uc.Do(context.Background(), "https://example.com/README.md") @@ -295,3 +297,24 @@ func TestDoReturnsTemporaryFileRemovalError(t *testing.T) { t.Fatalf("got error %v, want removal error", err) } } + +func TestRemoteMdPassesURLAsDisplayPath(t *testing.T) { + want := entity.Toc{"* [Title](#title)"} + grabber := &grabberStub{toc: &want} + uc := newUseCase( + t, + getterStub{body: []byte("# Title"), contentType: "text/plain"}, + createTemper(t), + writerStub{}, + converterStub{}, + grabber, + ) + + const documentURL = "https://example.com/README.md" + if _, err := uc.Do(context.Background(), documentURL); err != nil { + t.Fatal(err) + } + if grabber.gotPath != documentURL { + t.Errorf("got grabber path %q, want %q", grabber.gotPath, documentURL) + } +} From 50624c8701042fe8548521853cfe1439aceb2db1 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Thu, 13 Aug 2026 21:24:19 +0300 Subject: [PATCH 06/24] fix(toc): prefix links with the document path for multiple inputs --- Makefile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Makefile b/Makefile index bcf5d7e..d272309 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,11 @@ e2e: ${E2E_RUN_RHTML} --hide-header --hide-footer --indent=4 > ${E2E_DIR}/got9.md @diff ${E2E_DIR}/want3.md ${E2E_DIR}/got9.md + @echo "${bold}>> 4. Multiple files, links carry the document path ...${clear}" + go run ./cmd/${EXEC} --hide-footer ./README.md ./CHANGELOG.md > ${E2E_DIR}/got-combo.md + @grep -qF '](./README.md#' ${E2E_DIR}/got-combo.md + @grep -qF '](./CHANGELOG.md#' ${E2E_DIR}/got-combo.md + # Step 2: create the release tag locally. Does not push anything. release: test release-local @if git rev-parse -q --verify refs/tags/${TAG} >/dev/null; then \ From 4822cd7a898721c03baee0efe84ffd685b5373f8 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Thu, 13 Aug 2026 21:38:00 +0300 Subject: [PATCH 07/24] feat(cli): accept - as the STDIN marker --- cmd/gh-md-toc/config.go | 30 +++++++++++++++++++++++++++++- cmd/gh-md-toc/config_test.go | 20 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/cmd/gh-md-toc/config.go b/cmd/gh-md-toc/config.go index 3457d0a..0cd565e 100644 --- a/cmd/gh-md-toc/config.go +++ b/cmd/gh-md-toc/config.go @@ -1,6 +1,8 @@ package main import ( + "errors" + "gopkg.in/alecthomas/kingpin.v2" "github.com/ekalinin/github-markdown-toc.go/v2/internal/app" @@ -11,6 +13,7 @@ import ( const ( cliName = "gh-md-toc" defaultGitHubURL = "https://api.github.com" + stdinMarker = "-" ) type cliOptions struct { @@ -57,14 +60,39 @@ func newCLI() (*kingpin.Application, cliOptions) { return parser, options } +// extractStdinMarker removes the "-" STDIN marker from the argument list. The flag +// parser would otherwise try to read it as a flag. +func extractStdinMarker(args []string) ([]string, bool) { + found := false + rest := make([]string, 0, len(args)) + for _, arg := range args { + if arg == stdinMarker { + found = true + continue + } + rest = append(rest, arg) + } + return rest, found +} + func parseConfig(args []string) (app.Config, error) { + args, useStdin := extractStdinMarker(args) + parser, options := newCLI() if _, err := parser.Parse(args); err != nil { return app.Config{}, err } + files := *options.paths + if useStdin { + if len(files) > 0 { + return app.Config{}, errors.New(`the "-" STDIN marker cannot be combined with other paths`) + } + files = nil + } + return app.Config{ - Files: *options.paths, + Files: files, Serial: *options.serial, Presentation: app.PresentationConfig{ HideHeader: *options.hideHeader, diff --git a/cmd/gh-md-toc/config_test.go b/cmd/gh-md-toc/config_test.go index 8cb937c..8d58e45 100644 --- a/cmd/gh-md-toc/config_test.go +++ b/cmd/gh-md-toc/config_test.go @@ -180,3 +180,23 @@ func TestCLIHelpShowsCurrentDefaultsWithoutToken(t *testing.T) { t.Errorf("help contains GitHub token:\n%s", help) } } + +func TestParseConfigStdinMarker(t *testing.T) { + cfg, err := parseConfig([]string{"-"}) + if err != nil { + t.Fatal(err) + } + if len(cfg.Files) != 0 { + t.Errorf("got files %v, want none so STDIN is used", cfg.Files) + } +} + +func TestParseConfigStdinMarkerWithPaths(t *testing.T) { + _, err := parseConfig([]string{"-", "README.md"}) + if err == nil { + t.Fatal("got no error, want a usage error") + } + if !strings.Contains(err.Error(), "STDIN marker") { + t.Errorf("got error %q, want it to mention the STDIN marker", err) + } +} From 6e525442c74e2f6759a79ce10cf9a92fae361f13 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Thu, 13 Aug 2026 21:49:49 +0300 Subject: [PATCH 08/24] feat(github): read the token from token.txt next to the binary --- internal/adapters/tokenresolver.go | 41 ++++++++++++++++++ internal/adapters/tokenresolver_test.go | 57 +++++++++++++++++++++++++ internal/app/new.go | 7 +++ 3 files changed, 105 insertions(+) create mode 100644 internal/adapters/tokenresolver.go create mode 100644 internal/adapters/tokenresolver_test.go diff --git a/internal/adapters/tokenresolver.go b/internal/adapters/tokenresolver.go new file mode 100644 index 0000000..78eb334 --- /dev/null +++ b/internal/adapters/tokenresolver.go @@ -0,0 +1,41 @@ +package adapters + +import ( + "os" + "path/filepath" + "strings" +) + +const tokenFileName = "token.txt" + +// TokenResolver reads the GitHub token from a file next to the executable. It is the +// last fallback, after the --token flag and the GH_TOC_TOKEN environment variable. +type TokenResolver struct { + executable func() (string, error) +} + +func NewTokenResolver() *TokenResolver { + return NewTokenResolverX(os.Executable) +} + +func NewTokenResolverX(executable func() (string, error)) *TokenResolver { + return &TokenResolver{executable: executable} +} + +// Resolve returns the token found in token.txt, or an empty string when there is no +// such file. A missing file is not an error. +func (r *TokenResolver) Resolve() (string, error) { + path, err := r.executable() + if err != nil { + return "", nil + } + + data, err := os.ReadFile(filepath.Join(filepath.Dir(path), tokenFileName)) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + return strings.TrimSpace(string(data)), nil +} diff --git a/internal/adapters/tokenresolver_test.go b/internal/adapters/tokenresolver_test.go new file mode 100644 index 0000000..d118708 --- /dev/null +++ b/internal/adapters/tokenresolver_test.go @@ -0,0 +1,57 @@ +package adapters + +import ( + "os" + "path/filepath" + "testing" +) + +func TestTokenResolverResolve(t *testing.T) { + tests := []struct { + name string + contents string + write bool + want string + }{ + {name: "no file", write: false, want: ""}, + {name: "token with newline", write: true, contents: "abc123\n", want: "abc123"}, + {name: "token with spaces", write: true, contents: " abc123 ", want: "abc123"}, + {name: "empty file", write: true, contents: "", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if tt.write { + path := filepath.Join(dir, tokenFileName) + if err := os.WriteFile(path, []byte(tt.contents), 0600); err != nil { + t.Fatal(err) + } + } + resolver := NewTokenResolverX(func() (string, error) { + return filepath.Join(dir, "gh-md-toc"), nil + }) + + got, err := resolver.Resolve() + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Errorf("got token %q, want %q", got, tt.want) + } + }) + } +} + +func TestTokenResolverIgnoresUnknownExecutablePath(t *testing.T) { + resolver := NewTokenResolverX(func() (string, error) { + return "", os.ErrNotExist + }) + + got, err := resolver.Resolve() + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Errorf("got token %q, want an empty string", got) + } +} diff --git a/internal/app/new.go b/internal/app/new.go index 78bc782..8162d83 100644 --- a/internal/app/new.go +++ b/internal/app/new.go @@ -41,6 +41,13 @@ func New(cfg Config) (*App, error) { ctlCfg := controller.Config{Files: cfg.Files, Serial: cfg.Serial} log.Info("App.New: init adapters ...") + if cfg.GitHub.GHToken == "" { + token, err := adapters.NewTokenResolver().Resolve() + if err != nil { + return nil, fmt.Errorf("read token file: %w", err) + } + cfg.GitHub.GHToken = token + } httpClient := adapters.NewHTTPClient() checker := adapters.NewFileCheck(log) writer := adapters.NewFileWriter() From 9f07ac5b1cd74e0ce1890fa59f56fe0eb12c117e Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Thu, 13 Aug 2026 21:57:18 +0300 Subject: [PATCH 09/24] feat(cli): report os, arch and Go version in --version --- README.md | 8 +++++++- cmd/gh-md-toc/config.go | 2 +- internal/version/version.go | 12 ++++++++++++ internal/version/version_test.go | 23 +++++++++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 internal/version/version_test.go diff --git a/README.md b/README.md index 9b5cc8d..c7e6cb4 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,15 @@ $ wget https://github.com/ekalinin/github-markdown-toc.go/releases/download/1.1. $ tar xzvf gh-md-toc.linux.amd64.tgz gh-md-toc $ ./gh-md-toc --version -1.1.0 +2.0.1 + +os: darwin +arch: arm64 +go: go1.26.5 ``` +The first line is the bare version number, so scripts that parse `gh-md-toc --version` will continue to work. + Compiling from source --------------------- diff --git a/cmd/gh-md-toc/config.go b/cmd/gh-md-toc/config.go index 0cd565e..d877f68 100644 --- a/cmd/gh-md-toc/config.go +++ b/cmd/gh-md-toc/config.go @@ -33,7 +33,7 @@ type cliOptions struct { func newCLI() (*kingpin.Application, cliOptions) { parser := kingpin.New(cliName, "") - parser.Version(version.Version) + parser.Version(version.Full()) pathsDesc := "Local path or URL of the document to grab TOC. Read MD from stdin if not entered." options := cliOptions{ diff --git a/internal/version/version.go b/internal/version/version.go index 0072f5c..275ec78 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,5 +1,10 @@ package version +import ( + "fmt" + "runtime" +) + const ( // Version is a current app version Version = "2.0.1" @@ -21,3 +26,10 @@ func SupportedGHVersions() []string { GH_2024_03, } } + +// Full returns the multi-line version banner. The first line stays the bare version +// number, so scripts that parse `gh-md-toc --version` keep working. +func Full() string { + return fmt.Sprintf("%s\n\nos: %s\narch: %s\ngo: %s", + Version, runtime.GOOS, runtime.GOARCH, runtime.Version()) +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..0c7bcc9 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,23 @@ +package version + +import ( + "runtime" + "strings" + "testing" +) + +func TestFullStartsWithBareVersion(t *testing.T) { + lines := strings.Split(Full(), "\n") + if lines[0] != Version { + t.Errorf("got first line %q, want the bare version %q", lines[0], Version) + } +} + +func TestFullReportsPlatform(t *testing.T) { + full := Full() + for _, want := range []string{runtime.GOOS, runtime.GOARCH, runtime.Version()} { + if !strings.Contains(full, want) { + t.Errorf("got %q, want it to contain %q", full, want) + } + } +} From a29e3252783bc4929db1d6acfbb1fa435a18fc0d Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Thu, 13 Aug 2026 22:03:02 +0300 Subject: [PATCH 10/24] fix(readme): make --version sample consistent with linux.amd64 tarball --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c7e6cb4..ee5888c 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,8 @@ gh-md-toc $ ./gh-md-toc --version 2.0.1 -os: darwin -arch: arm64 +os: linux +arch: amd64 go: go1.26.5 ``` From e00a3ebc97ec089ca3f5b12589b9cd498f207c1f Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Thu, 13 Aug 2026 22:15:44 +0300 Subject: [PATCH 11/24] feat(insert): add TOC markers and the block replacement rule --- internal/core/entity/marker.go | 7 ++ internal/core/usecase/insertmd/markers.go | 55 +++++++++++++ .../core/usecase/insertmd/markers_test.go | 82 +++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 internal/core/entity/marker.go create mode 100644 internal/core/usecase/insertmd/markers.go create mode 100644 internal/core/usecase/insertmd/markers_test.go diff --git a/internal/core/entity/marker.go b/internal/core/entity/marker.go new file mode 100644 index 0000000..b1f85c9 --- /dev/null +++ b/internal/core/entity/marker.go @@ -0,0 +1,7 @@ +package entity + +// MarkerStart and MarkerEnd delimit the TOC block inside a Markdown document. +const ( + MarkerStart = "" + MarkerEnd = "" +) diff --git a/internal/core/usecase/insertmd/markers.go b/internal/core/usecase/insertmd/markers.go new file mode 100644 index 0000000..bdde9f0 --- /dev/null +++ b/internal/core/usecase/insertmd/markers.go @@ -0,0 +1,55 @@ +package insertmd + +import ( + "errors" + "strings" + + "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/entity" +) + +var ( + ErrMarkersNotFound = errors.New("no / markers found") + ErrMultipleMarkerPairs = errors.New("multiple / marker pairs found") + ErrMarkersOutOfOrder = errors.New("the marker precedes ") +) + +// replaceBetweenMarkers puts block between the TOC markers. The markers themselves +// and everything around them are left byte for byte as they were, so a document with +// CRLF endings keeps them outside the replaced block. +func replaceBetweenMarkers(content, block []byte) ([]byte, error) { + lines := strings.Split(string(content), "\n") + + startIdx, endIdx := -1, -1 + starts, ends := 0, 0 + for i, line := range lines { + switch strings.TrimSpace(line) { + case entity.MarkerStart: + starts++ + if startIdx < 0 { + startIdx = i + } + case entity.MarkerEnd: + ends++ + if endIdx < 0 { + endIdx = i + } + } + } + + switch { + case starts == 0 || ends == 0: + return nil, ErrMarkersNotFound + case starts > 1 || ends > 1: + return nil, ErrMultipleMarkerPairs + case endIdx < startIdx: + return nil, ErrMarkersOutOfOrder + } + + result := make([]string, 0, len(lines)) + result = append(result, lines[:startIdx+1]...) + if len(block) > 0 { + result = append(result, strings.Split(string(block), "\n")...) + } + result = append(result, lines[endIdx:]...) + return []byte(strings.Join(result, "\n")), nil +} diff --git a/internal/core/usecase/insertmd/markers_test.go b/internal/core/usecase/insertmd/markers_test.go new file mode 100644 index 0000000..c096f9f --- /dev/null +++ b/internal/core/usecase/insertmd/markers_test.go @@ -0,0 +1,82 @@ +package insertmd + +import ( + "errors" + "testing" +) + +func TestReplaceBetweenMarkers(t *testing.T) { + tests := []struct { + name string + content string + block string + want string + wantErr error + }{ + { + name: "replaces an existing block", + content: "# Title\n\n\nstale\nlines\n\n\n## Section\n", + block: "* [Title](#title)", + want: "# Title\n\n\n* [Title](#title)\n\n\n## Section\n", + }, + { + name: "fills an empty block", + content: "\n\n", + block: "* [A](#a)\n* [B](#b)", + want: "\n* [A](#a)\n* [B](#b)\n\n", + }, + { + name: "keeps indented markers and their indentation", + content: " \nstale\n \n", + block: "* [A](#a)", + want: " \n* [A](#a)\n \n", + }, + { + name: "tolerates CRLF line endings", + content: "# Title\r\n\r\nstale\r\n\r\n", + block: "* [Title](#title)", + want: "# Title\r\n\r\n* [Title](#title)\n\r\n", + }, + { + name: "no markers", + content: "# Title\n", + block: "* [Title](#title)", + wantErr: ErrMarkersNotFound, + }, + { + name: "only the start marker", + content: "\n# Title\n", + block: "* [Title](#title)", + wantErr: ErrMarkersNotFound, + }, + { + name: "two pairs", + content: "\n\n\n\n", + block: "* [A](#a)", + wantErr: ErrMultipleMarkerPairs, + }, + { + name: "reversed markers", + content: "\nbody\n\n", + block: "* [A](#a)", + wantErr: ErrMarkersOutOfOrder, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := replaceBetweenMarkers([]byte(tt.content), []byte(tt.block)) + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("got error %v, want %v", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if string(got) != tt.want { + t.Errorf("got\n%q\nwant\n%q", got, tt.want) + } + }) + } +} From eb8f944ceb19e20193914cea15efdd34c16e4ca5 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Thu, 13 Aug 2026 22:29:36 +0300 Subject: [PATCH 12/24] feat(adapters): add file reader, atomic write, backup, stamp and notify --- internal/adapters/filebackup.go | 46 +++++++++++++++++++++++++ internal/adapters/filebackup_test.go | 50 ++++++++++++++++++++++++++++ internal/adapters/filereader.go | 20 +++++++++++ internal/adapters/filereader_test.go | 33 ++++++++++++++++++ internal/adapters/filewriter.go | 39 ++++++++++++++++++++++ internal/adapters/filewriter_test.go | 46 +++++++++++++++++++++++++ internal/adapters/notifier.go | 23 +++++++++++++ internal/adapters/notifier_test.go | 20 +++++++++++ internal/adapters/stamper.go | 38 +++++++++++++++++++++ internal/adapters/stamper_test.go | 40 ++++++++++++++++++++++ 10 files changed, 355 insertions(+) create mode 100644 internal/adapters/filebackup.go create mode 100644 internal/adapters/filebackup_test.go create mode 100644 internal/adapters/filereader.go create mode 100644 internal/adapters/filereader_test.go create mode 100644 internal/adapters/notifier.go create mode 100644 internal/adapters/notifier_test.go create mode 100644 internal/adapters/stamper.go create mode 100644 internal/adapters/stamper_test.go diff --git a/internal/adapters/filebackup.go b/internal/adapters/filebackup.go new file mode 100644 index 0000000..14da87f --- /dev/null +++ b/internal/adapters/filebackup.go @@ -0,0 +1,46 @@ +package adapters + +import ( + "context" + "fmt" + "os" + "time" +) + +// backupTimeLayout matches the suffix the bash gh-md-toc uses for its backups. +const backupTimeLayout = "2006-01-02_150405" + +// FileBackupper copies a file next to itself before it gets rewritten. +type FileBackupper struct { + now func() time.Time +} + +func NewFileBackupper() *FileBackupper { + return NewFileBackupperX(time.Now) +} + +func NewFileBackupperX(now func() time.Time) *FileBackupper { + return &FileBackupper{now: now} +} + +// Backup copies file to ".orig." and returns the path of the copy. +func (b *FileBackupper) Backup(ctx context.Context, file string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + info, err := os.Stat(file) + if err != nil { + return "", err + } + data, err := os.ReadFile(file) + if err != nil { + return "", err + } + + backup := fmt.Sprintf("%s.orig.%s", file, b.now().Format(backupTimeLayout)) + if err := os.WriteFile(backup, data, info.Mode().Perm()); err != nil { + return "", err + } + return backup, nil +} diff --git a/internal/adapters/filebackup_test.go b/internal/adapters/filebackup_test.go new file mode 100644 index 0000000..dd8e617 --- /dev/null +++ b/internal/adapters/filebackup_test.go @@ -0,0 +1,50 @@ +package adapters + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestFileBackupperBackup(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "README.md") + if err := os.WriteFile(file, []byte("original\n"), 0640); err != nil { + t.Fatal(err) + } + stamp := time.Date(2026, 8, 12, 13, 45, 6, 0, time.UTC) + + got, err := NewFileBackupperX(func() time.Time { return stamp }).Backup(context.Background(), file) + if err != nil { + t.Fatal(err) + } + + want := file + ".orig.2026-08-12_134506" + if got != want { + t.Errorf("got backup path %q, want %q", got, want) + } + data, err := os.ReadFile(got) + if err != nil { + t.Fatal(err) + } + if string(data) != "original\n" { + t.Errorf("got backup contents %q, want %q", data, "original\n") + } + info, err := os.Stat(got) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0640 { + t.Errorf("got backup mode %v, want 0640", info.Mode().Perm()) + } +} + +func TestFileBackupperMissingFile(t *testing.T) { + dir := t.TempDir() + + if _, err := NewFileBackupper().Backup(context.Background(), filepath.Join(dir, "absent.md")); err == nil { + t.Fatal("got no error, want a missing file error") + } +} diff --git a/internal/adapters/filereader.go b/internal/adapters/filereader.go new file mode 100644 index 0000000..0c4a8dd --- /dev/null +++ b/internal/adapters/filereader.go @@ -0,0 +1,20 @@ +package adapters + +import ( + "context" + "os" +) + +// FileReader reads a whole file into memory. +type FileReader struct{} + +func NewFileReader() *FileReader { + return &FileReader{} +} + +func (f *FileReader) Read(ctx context.Context, file string) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return os.ReadFile(file) +} diff --git a/internal/adapters/filereader_test.go b/internal/adapters/filereader_test.go new file mode 100644 index 0000000..8c1e04d --- /dev/null +++ b/internal/adapters/filereader_test.go @@ -0,0 +1,33 @@ +package adapters + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestFileReaderRead(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "doc.md") + if err := os.WriteFile(file, []byte("# Title\n"), 0644); err != nil { + t.Fatal(err) + } + + got, err := NewFileReader().Read(context.Background(), file) + if err != nil { + t.Fatal(err) + } + if string(got) != "# Title\n" { + t.Errorf("got %q, want %q", got, "# Title\n") + } +} + +func TestFileReaderReadCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := NewFileReader().Read(ctx, "irrelevant.md"); err == nil { + t.Fatal("got no error, want context cancellation") + } +} diff --git a/internal/adapters/filewriter.go b/internal/adapters/filewriter.go index bb2e00b..dc20587 100644 --- a/internal/adapters/filewriter.go +++ b/internal/adapters/filewriter.go @@ -3,6 +3,7 @@ package adapters import ( "context" "os" + "path/filepath" ) type FileWriter struct{} @@ -17,3 +18,41 @@ func (f *FileWriter) Write(ctx context.Context, file string, data []byte) error } return os.WriteFile(file, data, 0644) } + +// WriteAtomic writes data through a temporary file in the same directory and renames +// it over the target, so a failed write can never truncate the original. The existing +// file mode is preserved. +func (f *FileWriter) WriteAtomic(ctx context.Context, file string, data []byte) (err error) { + if err := ctx.Err(); err != nil { + return err + } + + perm := os.FileMode(0644) + if info, statErr := os.Stat(file); statErr == nil { + perm = info.Mode().Perm() + } + + tmp, err := os.CreateTemp(filepath.Dir(file), filepath.Base(file)+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + if err != nil { + _ = os.Remove(tmpPath) + } + }() + + if _, err = tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err = tmp.Close(); err != nil { + return err + } + if err = os.Chmod(tmpPath, perm); err != nil { + return err + } + err = os.Rename(tmpPath, file) + return err +} diff --git a/internal/adapters/filewriter_test.go b/internal/adapters/filewriter_test.go index 6032713..b259a98 100644 --- a/internal/adapters/filewriter_test.go +++ b/internal/adapters/filewriter_test.go @@ -45,3 +45,49 @@ func Test_FileWriter(t *testing.T) { }) } } + +func TestFileWriterWriteAtomicPreservesMode(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "README.md") + if err := os.WriteFile(file, []byte("old\n"), 0640); err != nil { + t.Fatal(err) + } + + if err := NewFileWriter().WriteAtomic(context.Background(), file, []byte("new\n")); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if string(data) != "new\n" { + t.Errorf("got %q, want %q", data, "new\n") + } + info, err := os.Stat(file) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0640 { + t.Errorf("got mode %v, want 0640", info.Mode().Perm()) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Errorf("got %d files in the directory, want only the target file", len(entries)) + } +} + +func TestFileWriterWriteAtomicFailsWithoutDirectory(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "sub", "README.md") + + if err := NewFileWriter().WriteAtomic(context.Background(), file, []byte("new\n")); err == nil { + t.Fatal("got no error, want a failure for a missing directory") + } + if _, err := os.Stat(dir + "/sub"); !os.IsNotExist(err) { + t.Error("got a leftover directory, want nothing created") + } +} diff --git a/internal/adapters/notifier.go b/internal/adapters/notifier.go new file mode 100644 index 0000000..d6c145f --- /dev/null +++ b/internal/adapters/notifier.go @@ -0,0 +1,23 @@ +package adapters + +import ( + "fmt" + "io" +) + +// Notifier writes status messages for the user. They go to a stream separate from +// the TOC itself, because the worker pool emits them in completion order. +type Notifier struct { + w io.Writer +} + +func NewNotifier(w io.Writer) *Notifier { + return &Notifier{w: w} +} + +func (n *Notifier) Notify(format string, args ...any) { + if n.w == nil { + return + } + _, _ = fmt.Fprintf(n.w, format+"\n", args...) +} diff --git a/internal/adapters/notifier_test.go b/internal/adapters/notifier_test.go new file mode 100644 index 0000000..f2d00f4 --- /dev/null +++ b/internal/adapters/notifier_test.go @@ -0,0 +1,20 @@ +package adapters + +import ( + "bytes" + "testing" +) + +func TestNotifierNotify(t *testing.T) { + var buf bytes.Buffer + NewNotifier(&buf).Notify("!! TOC was added into: %q", "README.md") + + want := "!! TOC was added into: \"README.md\"\n" + if buf.String() != want { + t.Errorf("got %q, want %q", buf.String(), want) + } +} + +func TestNotifierNilWriter(t *testing.T) { + NewNotifier(nil).Notify("ignored %s", "message") +} diff --git a/internal/adapters/stamper.go b/internal/adapters/stamper.go new file mode 100644 index 0000000..17c737f --- /dev/null +++ b/internal/adapters/stamper.go @@ -0,0 +1,38 @@ +package adapters + +import ( + "fmt" + "os/user" + "time" +) + +// Stamper builds the signature comment written into a document next to an +// inserted TOC. +type Stamper struct { + now func() time.Time + username func() (string, error) +} + +func NewStamper() *Stamper { + return NewStamperX(time.Now, currentUsername) +} + +func NewStamperX(now func() time.Time, username func() (string, error)) *Stamper { + return &Stamper{now: now, username: username} +} + +func currentUsername() (string, error) { + current, err := user.Current() + if err != nil { + return "", err + } + return current.Username, nil +} + +func (s *Stamper) Stamp() string { + name, err := s.username() + if err != nil || name == "" { + name = "unknown" + } + return fmt.Sprintf("", name, s.now().Format(time.RFC3339)) +} diff --git a/internal/adapters/stamper_test.go b/internal/adapters/stamper_test.go new file mode 100644 index 0000000..f969020 --- /dev/null +++ b/internal/adapters/stamper_test.go @@ -0,0 +1,40 @@ +package adapters + +import ( + "errors" + "testing" + "time" +) + +func TestStamperStamp(t *testing.T) { + stamp := time.Date(2026, 8, 12, 13, 45, 6, 0, time.UTC) + tests := []struct { + name string + username func() (string, error) + want string + }{ + { + name: "known user", + username: func() (string, error) { return "ekalinin", nil }, + want: "", + }, + { + name: "lookup fails", + username: func() (string, error) { return "", errors.New("no user") }, + want: "", + }, + { + name: "empty user", + username: func() (string, error) { return "", nil }, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewStamperX(func() time.Time { return stamp }, tt.username).Stamp() + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} From b4bdfba3d24eb11f4a492b295e02d1291abc3206 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 11:06:43 +0300 Subject: [PATCH 13/24] fix(adapters): cover WriteAtomic temp cleanup, refuse backup overwrite --- internal/adapters/filebackup.go | 14 ++++++++++++- internal/adapters/filebackup_test.go | 30 ++++++++++++++++++++++++++++ internal/adapters/filewriter_test.go | 25 +++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/internal/adapters/filebackup.go b/internal/adapters/filebackup.go index 14da87f..9db3f6d 100644 --- a/internal/adapters/filebackup.go +++ b/internal/adapters/filebackup.go @@ -24,6 +24,9 @@ func NewFileBackupperX(now func() time.Time) *FileBackupper { } // Backup copies file to ".orig." and returns the path of the copy. +// An existing backup is never overwritten. The timestamp has one-second granularity, +// so a second run within the same second would otherwise replace the pristine copy +// with an already-rewritten one. func (b *FileBackupper) Backup(ctx context.Context, file string) (string, error) { if err := ctx.Err(); err != nil { return "", err @@ -39,8 +42,17 @@ func (b *FileBackupper) Backup(ctx context.Context, file string) (string, error) } backup := fmt.Sprintf("%s.orig.%s", file, b.now().Format(backupTimeLayout)) - if err := os.WriteFile(backup, data, info.Mode().Perm()); err != nil { + dst, err := os.OpenFile(backup, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm()) + if err != nil { return "", err } + _, writeErr := dst.Write(data) + closeErr := dst.Close() + if writeErr != nil { + return "", writeErr + } + if closeErr != nil { + return "", closeErr + } return backup, nil } diff --git a/internal/adapters/filebackup_test.go b/internal/adapters/filebackup_test.go index dd8e617..fc50ab3 100644 --- a/internal/adapters/filebackup_test.go +++ b/internal/adapters/filebackup_test.go @@ -48,3 +48,33 @@ func TestFileBackupperMissingFile(t *testing.T) { t.Fatal("got no error, want a missing file error") } } + +func TestFileBackupperRefusesToOverwriteExistingBackup(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "README.md") + if err := os.WriteFile(file, []byte("original\n"), 0644); err != nil { + t.Fatal(err) + } + stamp := time.Date(2026, 8, 12, 13, 45, 6, 0, time.UTC) + backupper := NewFileBackupperX(func() time.Time { return stamp }) + + first, err := backupper.Backup(context.Background(), file) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("changed\n"), 0644); err != nil { + t.Fatal(err) + } + + if _, err := backupper.Backup(context.Background(), file); err == nil { + t.Fatal("got no error, want a refusal to overwrite the existing backup") + } + + data, err := os.ReadFile(first) + if err != nil { + t.Fatal(err) + } + if string(data) != "original\n" { + t.Errorf("got backup contents %q, want the pristine original", data) + } +} diff --git a/internal/adapters/filewriter_test.go b/internal/adapters/filewriter_test.go index b259a98..df038e3 100644 --- a/internal/adapters/filewriter_test.go +++ b/internal/adapters/filewriter_test.go @@ -91,3 +91,28 @@ func TestFileWriterWriteAtomicFailsWithoutDirectory(t *testing.T) { t.Error("got a leftover directory, want nothing created") } } + +func TestFileWriterWriteAtomicRemovesTempFileOnFailure(t *testing.T) { + dir := t.TempDir() + // Renaming onto a directory fails, and by then the temp file exists. + target := filepath.Join(dir, "target") + if err := os.Mkdir(target, 0755); err != nil { + t.Fatal(err) + } + + if err := NewFileWriter().WriteAtomic(context.Background(), target, []byte("new\n")); err == nil { + t.Fatal("got no error, want the rename onto a directory to fail") + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != "target" { + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + t.Errorf("got directory entries %v, want only the target - the temp file must be removed", names) + } +} From 428b6ef08a34937292a3d69c21704658a1496383 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 11:26:20 +0300 Subject: [PATCH 14/24] fix(adapters): remove partial backup file on write or close failure --- internal/adapters/filebackup.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/adapters/filebackup.go b/internal/adapters/filebackup.go index 9db3f6d..b878a10 100644 --- a/internal/adapters/filebackup.go +++ b/internal/adapters/filebackup.go @@ -2,6 +2,7 @@ package adapters import ( "context" + "errors" "fmt" "os" "time" @@ -48,11 +49,10 @@ func (b *FileBackupper) Backup(ctx context.Context, file string) (string, error) } _, writeErr := dst.Write(data) closeErr := dst.Close() - if writeErr != nil { - return "", writeErr - } - if closeErr != nil { - return "", closeErr + if err := errors.Join(writeErr, closeErr); err != nil { + // The exclusive create already claimed the path. Leaving a partial copy there + // would block a retry with EEXIST and hide the error that actually happened. + return "", errors.Join(err, os.Remove(backup)) } return backup, nil } From 2c3d0c518e5c79aeda439a092b788ed0f78a38cb Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 12:56:57 +0300 Subject: [PATCH 15/24] feat(insert): add the insertmd use case --- internal/core/usecase/insertmd/insertmd.go | 124 +++++++++++++++ .../core/usecase/insertmd/insertmd_test.go | 149 ++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 internal/core/usecase/insertmd/insertmd.go create mode 100644 internal/core/usecase/insertmd/insertmd_test.go diff --git a/internal/core/usecase/insertmd/insertmd.go b/internal/core/usecase/insertmd/insertmd.go new file mode 100644 index 0000000..b330272 --- /dev/null +++ b/internal/core/usecase/insertmd/insertmd.go @@ -0,0 +1,124 @@ +package insertmd + +import ( + "context" + "fmt" + "strings" + + "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/entity" +) + +// createdBy is the attribution written into the document, next to the TOC. +const createdBy = "" + +type useCase interface { + Do(context.Context, string) (entity.Toc, error) +} + +type fileReader interface { + Read(context.Context, string) ([]byte, error) +} + +type atomicWriter interface { + WriteAtomic(context.Context, string, []byte) error +} + +type fileBackupper interface { + Backup(context.Context, string) (string, error) +} + +type stamper interface { + Stamp() string +} + +type notifier interface { + Notify(string, ...any) +} + +type logger interface { + Info(string, ...any) +} + +// Config controls how the TOC block is written back into the document. +type Config struct { + NoBackup bool + HideFooter bool +} + +// - delegate to the inner use case to build the TOC +// - back up the original file +// - rewrite the block between and +type InsertMd struct { + cfg Config + inner useCase + reader fileReader + writer atomicWriter + backupper fileBackupper + stamp stamper + notify notifier + log logger +} + +func New(cfg Config, inner useCase, reader fileReader, writer atomicWriter, + backupper fileBackupper, stamp stamper, notify notifier, log logger) *InsertMd { + return &InsertMd{ + cfg: cfg, + inner: inner, + reader: reader, + writer: writer, + backupper: backupper, + stamp: stamp, + notify: notify, + log: log, + } +} + +// block renders what goes between the markers: the TOC itself plus, unless the +// footer is hidden, the attribution and the signature. +func (uc *InsertMd) block(toc entity.Toc) []byte { + lines := make([]string, 0, len(toc)+3) + lines = append(lines, toc...) + if !uc.cfg.HideFooter { + lines = append(lines, "", createdBy, uc.stamp.Stamp()) + } + return []byte(strings.Join(lines, "\n")) +} + +func (uc *InsertMd) Do(ctx context.Context, file string) (entity.Toc, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("insert TOC into %q: %w", file, err) + } + + uc.log.Info("InsertMD: start", "file", file) + toc, err := uc.inner.Do(ctx, file) + if err != nil { + return nil, err + } + + content, err := uc.reader.Read(ctx, file) + if err != nil { + return nil, fmt.Errorf("read %q for TOC insertion: %w", file, err) + } + + // Validate before the backup, so a document without markers leaves nothing behind. + updated, err := replaceBetweenMarkers(content, uc.block(toc)) + if err != nil { + return nil, fmt.Errorf("insert TOC into %q: %w", file, err) + } + + if !uc.cfg.NoBackup { + backup, backupErr := uc.backupper.Backup(ctx, file) + if backupErr != nil { + return nil, fmt.Errorf("back up %q: %w", file, backupErr) + } + uc.notify.Notify("!! Origin version of the file: %q", backup) + } + + if err := uc.writer.WriteAtomic(ctx, file, updated); err != nil { + return nil, fmt.Errorf("insert TOC into %q: %w", file, err) + } + uc.notify.Notify("!! TOC was added into: %q", file) + + uc.log.Info("InsertMD: done.") + return toc, nil +} diff --git a/internal/core/usecase/insertmd/insertmd_test.go b/internal/core/usecase/insertmd/insertmd_test.go new file mode 100644 index 0000000..cfb2e74 --- /dev/null +++ b/internal/core/usecase/insertmd/insertmd_test.go @@ -0,0 +1,149 @@ +package insertmd + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/entity" +) + +type innerStub struct { + toc entity.Toc + err error +} + +func (s innerStub) Do(context.Context, string) (entity.Toc, error) { return s.toc, s.err } + +type readerStub struct { + data []byte + err error +} + +func (s readerStub) Read(context.Context, string) ([]byte, error) { return s.data, s.err } + +type writerSpy struct { + got []byte + err error +} + +func (s *writerSpy) WriteAtomic(_ context.Context, _ string, data []byte) error { + s.got = data + return s.err +} + +type backupperSpy struct { + calls int + err error +} + +func (s *backupperSpy) Backup(_ context.Context, file string) (string, error) { + s.calls++ + return file + ".orig.2026-08-12_134506", s.err +} + +type stamperStub struct{} + +func (stamperStub) Stamp() string { return "" } + +type notifierSpy struct{ messages []string } + +func (s *notifierSpy) Notify(format string, args ...any) { + s.messages = append(s.messages, fmt.Sprintf(format, args...)) +} + +type loggerStub struct{} + +func (loggerStub) Info(string, ...any) {} + +func newTestInsertMd(cfg Config, content string, toc entity.Toc) (*InsertMd, *writerSpy, *backupperSpy, *notifierSpy) { + writer := &writerSpy{} + backupper := &backupperSpy{} + notify := ¬ifierSpy{} + uc := New(cfg, innerStub{toc: toc}, readerStub{data: []byte(content)}, writer, + backupper, stamperStub{}, notify, loggerStub{}) + return uc, writer, backupper, notify +} + +func TestInsertMdWritesBlockWithFooter(t *testing.T) { + content := "# Title\n\n\nstale\n\n\n## Section\n" + uc, writer, backupper, notify := newTestInsertMd(Config{}, content, entity.Toc{"* [Title](#title)"}) + + got, err := uc.Do(context.Background(), "README.md") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0] != "* [Title](#title)" { + t.Errorf("got TOC %v, want it returned unchanged", got) + } + + want := "# Title\n\n\n" + + "* [Title](#title)\n" + + "\n" + + "\n" + + "\n" + + "\n\n## Section\n" + if string(writer.got) != want { + t.Errorf("got written file\n%q\nwant\n%q", writer.got, want) + } + if backupper.calls != 1 { + t.Errorf("got %d backup calls, want 1", backupper.calls) + } + if len(notify.messages) != 2 { + t.Errorf("got messages %v, want the backup and the insert notice", notify.messages) + } +} + +func TestInsertMdHideFooter(t *testing.T) { + content := "\n\n" + uc, writer, _, _ := newTestInsertMd(Config{HideFooter: true}, content, entity.Toc{"* [Title](#title)"}) + + if _, err := uc.Do(context.Background(), "README.md"); err != nil { + t.Fatal(err) + } + if strings.Contains(string(writer.got), "Added by") { + t.Errorf("got written file %q, want no signature comment", writer.got) + } +} + +func TestInsertMdNoBackup(t *testing.T) { + content := "\n\n" + uc, _, backupper, notify := newTestInsertMd(Config{NoBackup: true}, content, entity.Toc{"* [A](#a)"}) + + if _, err := uc.Do(context.Background(), "README.md"); err != nil { + t.Fatal(err) + } + if backupper.calls != 0 { + t.Errorf("got %d backup calls, want none", backupper.calls) + } + if len(notify.messages) != 1 { + t.Errorf("got messages %v, want only the insert notice", notify.messages) + } +} + +func TestInsertMdMissingMarkersLeavesFileAlone(t *testing.T) { + uc, writer, backupper, _ := newTestInsertMd(Config{}, "# Title\n", entity.Toc{"* [Title](#title)"}) + + _, err := uc.Do(context.Background(), "README.md") + if !errors.Is(err, ErrMarkersNotFound) { + t.Fatalf("got error %v, want ErrMarkersNotFound", err) + } + if writer.got != nil { + t.Errorf("got a write of %q, want none", writer.got) + } + if backupper.calls != 0 { + t.Errorf("got %d backup calls, want none - validation runs first", backupper.calls) + } +} + +func TestInsertMdPropagatesInnerError(t *testing.T) { + innerErr := errors.New("grab failed") + uc := New(Config{}, innerStub{err: innerErr}, readerStub{}, &writerSpy{}, + &backupperSpy{}, stamperStub{}, ¬ifierSpy{}, loggerStub{}) + + if _, err := uc.Do(context.Background(), "README.md"); !errors.Is(err, innerErr) { + t.Fatalf("got error %v, want the inner error", err) + } +} From b5b538e402b8667301e0b28ec792ac6aa70e077f Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 13:18:51 +0300 Subject: [PATCH 16/24] test(insert): add error-path coverage for insertmd --- .../core/usecase/insertmd/insertmd_test.go | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/internal/core/usecase/insertmd/insertmd_test.go b/internal/core/usecase/insertmd/insertmd_test.go index cfb2e74..b400696 100644 --- a/internal/core/usecase/insertmd/insertmd_test.go +++ b/internal/core/usecase/insertmd/insertmd_test.go @@ -147,3 +147,56 @@ func TestInsertMdPropagatesInnerError(t *testing.T) { t.Fatalf("got error %v, want the inner error", err) } } + +func TestInsertMdReadFailureLeavesFileAlone(t *testing.T) { + readErr := errors.New("read failed") + writer := &writerSpy{} + backupper := &backupperSpy{} + uc := New(Config{}, innerStub{toc: entity.Toc{"* [A](#a)"}}, readerStub{err: readErr}, writer, + backupper, stamperStub{}, ¬ifierSpy{}, loggerStub{}) + + if _, err := uc.Do(context.Background(), "README.md"); !errors.Is(err, readErr) { + t.Fatalf("got error %v, want the read error", err) + } + if writer.got != nil { + t.Errorf("got a write of %q, want none", writer.got) + } + if backupper.calls != 0 { + t.Errorf("got %d backup calls, want none", backupper.calls) + } +} + +func TestInsertMdBackupFailureSkipsTheWrite(t *testing.T) { + backupErr := errors.New("backup failed") + writer := &writerSpy{} + notify := ¬ifierSpy{} + uc := New(Config{}, innerStub{toc: entity.Toc{"* [A](#a)"}}, + readerStub{data: []byte("\n\n")}, writer, + &backupperSpy{err: backupErr}, stamperStub{}, notify, loggerStub{}) + + if _, err := uc.Do(context.Background(), "README.md"); !errors.Is(err, backupErr) { + t.Fatalf("got error %v, want the backup error", err) + } + if writer.got != nil { + t.Errorf("got a write of %q, want none - a failed backup must never be followed by a rewrite", writer.got) + } + if len(notify.messages) != 0 { + t.Errorf("got messages %v, want none - nothing succeeded", notify.messages) + } +} + +func TestInsertMdWriteFailurePropagates(t *testing.T) { + writeErr := errors.New("write failed") + writer := &writerSpy{err: writeErr} + notify := ¬ifierSpy{} + uc := New(Config{NoBackup: true}, innerStub{toc: entity.Toc{"* [A](#a)"}}, + readerStub{data: []byte("\n\n")}, writer, + &backupperSpy{}, stamperStub{}, notify, loggerStub{}) + + if _, err := uc.Do(context.Background(), "README.md"); !errors.Is(err, writeErr) { + t.Fatalf("got error %v, want the write error", err) + } + if len(notify.messages) != 0 { + t.Errorf("got messages %v, want none - the insert notice must not claim a write that failed", notify.messages) + } +} From 5e3ca98c53185255837f4182a64a15b1c597e6ce Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 15:11:19 +0300 Subject: [PATCH 17/24] feat(insert): add --insert and --no-backup --- ARCHITECTURE.md | 103 ++++++++++++++++++++++++++++++----- Makefile | 13 ++++- README.md | 33 +++++++++++ cmd/gh-md-toc/config.go | 18 ++++++ cmd/gh-md-toc/config_test.go | 20 +++++++ cmd/gh-md-toc/main.go | 2 +- e2e-tests/insert-src.md | 8 +++ e2e-tests/want-insert.md | 11 ++++ e2e-tests/want.md | 1 + e2e-tests/want3.md | 1 + internal/app/config.go | 7 +++ internal/app/new.go | 42 ++++++++++++-- internal/app/new_test.go | 5 +- internal/app/run.go | 9 +++ internal/app/run_test.go | 23 ++++++++ 15 files changed, 271 insertions(+), 25 deletions(-) create mode 100644 e2e-tests/insert-src.md create mode 100644 e2e-tests/want-insert.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 96311f4..76f0e28 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -42,6 +42,7 @@ cmd/gh-md-toc │ └── input routing, concurrency, and result output ├── internal/core/usecase │ ├── localmd + │ ├── insertmd (wraps localmd, only when --insert is set) │ ├── remotemd │ └── remotehtml ├── internal/core/toc @@ -56,7 +57,8 @@ cmd/gh-md-toc internal/core/entity ├── Heading ├── Toc -└── Type +├── Type +└── MarkerStart / MarkerEnd ``` ## Dependency direction @@ -93,9 +95,13 @@ Important dependency rules: ```mermaid flowchart TD AppNew[app.New] --> Logger + AppNew --> Notifier AppNew --> HTTPClient[http.Client] AppNew --> FileChecker AppNew --> FileWriter + AppNew --> FileReader + AppNew --> FileBackupper + AppNew --> Stamper AppNew --> FileTemper AppNew --> RemotePoster AppNew --> RemoteGetter @@ -119,6 +125,14 @@ flowchart TD RegexpGenerator --> LocalMd Logger --> LocalMd + LocalMd --> InsertMd + FileReader --> InsertMd + FileWriter --> InsertMd + FileBackupper --> InsertMd + Stamper --> InsertMd + Notifier --> InsertMd + Logger --> InsertMd + RemoteGetter --> RemoteMd FileTemper --> RemoteMd LocalMd --> RemoteMd @@ -129,7 +143,8 @@ flowchart TD JSONGenerator --> RemoteHTML Logger --> RemoteHTML - LocalMd --> Controller + LocalMd -.->|"--insert not set"| Controller + InsertMd -.->|"--insert set"| Controller RemoteMd --> Controller RemoteHTML --> Controller Logger --> Controller @@ -138,16 +153,25 @@ flowchart TD The shared `http.Client` gives all remote operations the same timeout configuration. The shared `Renderer` gives both extraction paths the same TOC formatting behavior. +`InsertMd` wraps `LocalMd`, it does not replace it: `app.New` always builds the plain +`LocalMd` and only builds `InsertMd` around it when `cfg.Insert.Enabled` is true. The +controller then receives whichever of the two implements the local-file use case for +that run. `RemoteMd` always keeps a direct reference to the unwrapped `LocalMd`, +never to `InsertMd`, because it processes a downloaded temporary file and must never +have a TOC written back into it. + ## Main types and responsibilities | Package | Type | Responsibility | |---|---|---| -| `internal/app` | `App` | Runs presentation logic before and after controller processing. | -| `internal/app` | `Config` | Holds execution, presentation, GitHub, and TOC settings. | +| `internal/app` | `App` | Runs presentation logic before and after controller processing, and warns about remote inputs when `--insert` is set. | +| `internal/app` | `Config` | Holds execution, presentation, GitHub, TOC, and insert settings. | | `internal/app` | `PresentationConfig` | Controls header and footer visibility. | | `internal/app` | `GitHubConfig` | Holds the GitHub token, API URL, and regexp layout version. | +| `internal/app` | `InsertConfig` | Controls whether the TOC is written into the source document and whether a backup is kept. | | `internal/controller` | `Controller` | Selects a use case, runs document jobs, preserves output order, and aggregates errors. | | `internal/core/usecase/localmd` | `LocalMd` | Validates a local file, converts Markdown through GitHub, and generates a TOC from returned HTML. | +| `internal/core/usecase/insertmd` | `InsertMd` | Wraps `LocalMd`, then backs up and rewrites the block between the TOC markers in the source file. | | `internal/core/usecase/remotemd` | `RemoteMd` | Downloads raw Markdown to a temporary file and delegates processing to `LocalMd`. | | `internal/core/usecase/remotehtml` | `RemoteHTML` | Downloads GitHub JSON data and generates a TOC through the JSON path. | | `internal/core/toc` | `Generator` | Combines a heading extractor with the shared renderer. | @@ -155,13 +179,18 @@ The shared `http.Client` gives all remote operations the same timeout configurat | `internal/core/entity` | `Heading` | Represents a parsed heading before Markdown rendering. | | `internal/core/entity` | `Toc` | Represents the generated TOC as Markdown lines and prints it. | | `internal/core/entity` | `Type` | Classifies an input as local Markdown, remote raw Markdown, or remote HTML. | +| `internal/core/entity` | `MarkerStart` / `MarkerEnd` | The `` / `` marker strings that delimit the TOC block inside a document. | | `internal/adapters` | `HTMLConverter` | Sends local Markdown to the GitHub Markdown API. | | `internal/adapters` | `RemotePoster` | Sends a file through the configured HTTP client. | | `internal/adapters` | `RemoteGetter` | Downloads remote content through the configured HTTP client. | | `internal/adapters` | `RegexpExtractor` | Extracts headings from GitHub-rendered HTML. | | `internal/adapters` | `JSONExtractor` | Extracts headings from a GitHub JSON response. | | `internal/adapters` | `FileChecker` | Checks whether a local file exists. | -| `internal/adapters` | `FileWriter` | Writes debug content to a file. | +| `internal/adapters` | `FileWriter` | Writes debug content to a file; also writes the rewritten document atomically for `InsertMd`. | +| `internal/adapters` | `FileReader` | Reads a whole file into memory, for `InsertMd` to locate the markers. | +| `internal/adapters` | `FileBackupper` | Copies a file to `.orig.` before `InsertMd` rewrites it. | +| `internal/adapters` | `Stamper` | Builds the signature comment recording who ran the insert and when. | +| `internal/adapters` | `Notifier` | Writes status messages, such as the backup path or a non-local-input warning, to stderr. | | `internal/adapters` | `FileTemper` | Creates and removes temporary files. | | `internal/adapters` | `Logger` | Enables structured logging only in debug mode. | @@ -172,15 +201,24 @@ Interfaces are intentionally small and located next to the consuming code. | Consumer | Interface | Implemented by | |---|---|---| | `app.App` | `app.Controller` | `*controller.Controller` | -| `controller.Controller` | `controller.useCase` | `*localmd.LocalMd`, `*remotemd.RemoteMd`, `*remotehtml.RemoteHTML` | +| `app.App` | `app.useCase` (used by `app.New`, not stored on `App`) | `*localmd.LocalMd`, `*insertmd.InsertMd` | +| `app.App` | `app.notifier` | `*adapters.Notifier` | +| `controller.Controller` | `controller.useCase` | `*localmd.LocalMd`, `*insertmd.InsertMd`, `*remotemd.RemoteMd`, `*remotehtml.RemoteHTML` | | `controller.Controller` | `controller.logger` | `*adapters.Logger` | | `localmd.LocalMd` | `localmd.fileChecker` | `*adapters.FileChecker` | | `localmd.LocalMd` | `localmd.fileWriter` | `*adapters.FileWriter` | | `localmd.LocalMd` | `localmd.htmlConverter` | `*adapters.HTMLConverter` | | `localmd.LocalMd` | `localmd.tocGrabber` | `*toc.Generator` | | `localmd.LocalMd` | `localmd.logger` | `*adapters.Logger` | +| `insertmd.InsertMd` | `insertmd.useCase` | `*localmd.LocalMd` | +| `insertmd.InsertMd` | `insertmd.fileReader` | `*adapters.FileReader` | +| `insertmd.InsertMd` | `insertmd.atomicWriter` | `*adapters.FileWriter` | +| `insertmd.InsertMd` | `insertmd.fileBackupper` | `*adapters.FileBackupper` | +| `insertmd.InsertMd` | `insertmd.stamper` | `*adapters.Stamper` | +| `insertmd.InsertMd` | `insertmd.notifier` | `*adapters.Notifier` | +| `insertmd.InsertMd` | `insertmd.logger` | `*adapters.Logger` | | `remotemd.RemoteMd` | `remotemd.remoteGetter` | `*adapters.RemoteGetter` | -| `remotemd.RemoteMd` | `remotemd.markdownProcessor` | `*localmd.LocalMd` | +| `remotemd.RemoteMd` | `remotemd.markdownProcessor` | `*localmd.LocalMd` (always the unwrapped use case, never `*insertmd.InsertMd`) | | `remotemd.RemoteMd` | `remotemd.fileTemper` | `*adapters.FileTemper` | | `remotemd.RemoteMd` | `remotemd.logger` | `*adapters.Logger` | | `remotehtml.RemoteHTML` | `remotehtml.remoteGetter` | `*adapters.RemoteGetter` | @@ -206,12 +244,15 @@ app.Config │ ├── GHToken string │ ├── GHUrl string │ └── GHVersion string -└── TOC toc.Config - ├── AbsolutePaths bool - ├── StartDepth int - ├── Depth int - ├── Escape bool - └── Indent int +├── TOC toc.Config +│ ├── AbsolutePaths bool +│ ├── StartDepth int +│ ├── Depth int +│ ├── Escape bool +│ └── Indent int +└── Insert app.InsertConfig + ├── Enabled bool + └── NoBackup bool ``` `cmd/gh-md-toc` maps flags and environment variables into this structure. `app.New` derives `TOC.AbsolutePaths` from whether the CLI received multiple file arguments, matching bash `gh-md-toc`, which drops the prefix when a single document is requested. @@ -222,7 +263,9 @@ Only the settings required at runtime are passed further: - `LocalMd` and `RemoteHTML` receive `Debug`; - `RemoteMd` receives no configuration; - `Renderer` receives `toc.Config`; -- GitHub settings are used when constructing `HTMLConverter` and `RegexpExtractor`. +- GitHub settings are used when constructing `HTMLConverter` and `RegexpExtractor`; +- `InsertMd` receives `Insert.NoBackup` and `Presentation.HideFooter`; `app.Run` reads + `Insert.Enabled` directly to decide whether to warn about non-local inputs. ## Input routing @@ -254,6 +297,38 @@ Controller When debug mode is enabled, `LocalMd` writes the returned HTML to `.debug.html` through `FileWriter`. +### Insert into the source file (`--insert`) + +```text +Controller + -> InsertMd.Do + -> LocalMd.Do (builds the TOC, as above) + -> FileReader.Read + -> replaceBetweenMarkers (validate and rewrite the / block) + -> FileBackupper.Backup (skipped when --no-backup is set) + -> FileWriter.WriteAtomic + -> Notifier.Notify + -> entity.Toc +``` + +`InsertMd` wraps `LocalMd` rather than replacing it: it delegates to `LocalMd.Do` to +obtain the TOC, then reads the current file, validates that it has exactly one +`` / `` marker pair in order, and rewrites only the block between +them, byte for byte outside that block. The backup step runs before the rewrite so a +failed rewrite still leaves a pristine copy on disk; it is skipped when +`Insert.NoBackup` is set. `FileWriter.WriteAtomic` writes through a temporary file in +the same directory and renames it over the target, so a failed write cannot truncate +the original. `Notifier` reports the backup path and the rewritten path on stderr, +separate from the TOC printed to stdout. + +`app.New` only builds `InsertMd` when `cfg.Insert.Enabled` is true; otherwise the +controller receives the plain `LocalMd` for local files, unchanged from before this +use case existed. `RemoteMd` is wired with the unwrapped `LocalMd` unconditionally, so +downloading a remote document and then applying `--insert` to it never happens - the +temporary file `RemoteMd` creates cannot be the target of a rewrite. `app.Run` warns +on stderr, once per input, about any file passed alongside `--insert` that is not +`entity.TypeLocalMD`, and otherwise leaves the document unmodified. + ### Remote raw Markdown ```text diff --git a/Makefile b/Makefile index d272309..63ab20f 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,12 @@ CMD_SRC=cmd/${EXEC}/main.go BUILD_DIR=build E2E_DIR=e2e-tests E2E_RUN=go run ./cmd/${EXEC} ./README.md -E2E_RUN_RHTML=go run ./cmd/${EXEC} https://github.com/ekalinin/github-markdown-toc.go/blob/master/README.md -E2E_RUN_RMD=go run ./cmd/${EXEC} https://raw.githubusercontent.com/ekalinin/github-markdown-toc.go/master/README.md +# The remote e2e sections read README.md from GitHub. `master` is right once a branch +# is merged; to run them on an unmerged branch, push it and pass its commit: +# make e2e E2E_REF=$(shell git rev-parse HEAD) +E2E_REF?=master +E2E_RUN_RHTML=go run ./cmd/${EXEC} https://github.com/ekalinin/github-markdown-toc.go/blob/${E2E_REF}/README.md +E2E_RUN_RMD=go run ./cmd/${EXEC} https://raw.githubusercontent.com/ekalinin/github-markdown-toc.go/${E2E_REF}/README.md VERSION=$(shell grep "\tVersion" internal/version/version.go | grep -o -E '[0-9]\.[0-9]\.[0-9]{1,2}') TAG=v${VERSION} bold := $(shell tput bold) @@ -63,6 +67,11 @@ e2e: @grep -qF '](./README.md#' ${E2E_DIR}/got-combo.md @grep -qF '](./CHANGELOG.md#' ${E2E_DIR}/got-combo.md + @echo "${bold}>> 5. Insert into a local file ...${clear}" + @cp ${E2E_DIR}/insert-src.md ${E2E_DIR}/got-insert.md + go run ./cmd/${EXEC} --insert --no-backup --hide-footer ${E2E_DIR}/got-insert.md > /dev/null + @diff ${E2E_DIR}/want-insert.md ${E2E_DIR}/got-insert.md + # Step 2: create the release tag locally. Does not push anything. release: test release-local @if git rev-parse -q --verify refs/tags/${TAG} >/dev/null; then \ diff --git a/README.md b/README.md index ee5888c..3142ab2 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Table of Contents * [Remote files](#remote-files) * [Multiple files](#multiple-files) * [Combo](#combo) + * [Insert into a file](#insert-into-a-file) * [Starting Depth](#starting-depth) * [Depth](#depth) * [No Escape](#no-escape) @@ -101,6 +102,8 @@ Flags: GitHub URL. Default: https://api.github.com --re-version=2024-03 RegExp version. Default: 2024-03 + --insert Insert the TOC into the file, between and . Local files only + --no-backup Do not keep a backup copy of the file. Requires --insert --version Show application version. Args: @@ -308,6 +311,36 @@ You can easily combine both ways: Created by [gh-md-toc](https://github.com/ekalinin/github-markdown-toc) ``` +Insert into a file +------------------ + +`gh-md-toc` can write the TOC directly into a document instead of only printing it. +Add a marker line containing `` where the TOC should start, and below it a +marker line containing `` where it should end - each marker needs its own +line, with nothing else on it besides surrounding whitespace. Then run: + +```bash +$ ./gh-md-toc --insert README.md +``` + +Everything between the two markers is replaced with the generated TOC; the markers +themselves and the rest of the document are left untouched. The `Table of Contents` +heading is not written into the file, only the list itself. + +`--insert` only works on local files. A remote URL passed alongside `--insert` is +reported as not local and left unmodified, instead of failing the whole run. + +Before rewriting the file, a backup copy is kept next to it, named +`.orig.`. Pass `--no-backup` to skip the backup; that flag requires +`--insert` and is rejected on its own. + +Unless `--hide-footer` is set, an attribution comment and a signature comment (who +ran the command, and when) are written right after the TOC, inside the markers. +`--hide-footer` suppresses both. + +Status messages - the backup path, or a warning about a non-local input - are +printed to stderr, not stdout. + Starting Depth -------------- diff --git a/cmd/gh-md-toc/config.go b/cmd/gh-md-toc/config.go index d877f68..2a87c91 100644 --- a/cmd/gh-md-toc/config.go +++ b/cmd/gh-md-toc/config.go @@ -29,6 +29,8 @@ type cliOptions struct { debug *bool githubURL *string reVersion *string + insert *bool + noBackup *bool } func newCLI() (*kingpin.Application, cliOptions) { @@ -55,6 +57,14 @@ func newCLI() (*kingpin.Application, cliOptions) { "re-version", "RegExp version. Default: "+version.GH_2024_03, ).Default(version.GH_2024_03).Enum(version.SupportedGHVersions()...), + insert: parser.Flag( + "insert", + "Insert the TOC into the file, between and . Local files only", + ).Bool(), + noBackup: parser.Flag( + "no-backup", + "Do not keep a backup copy of the file. Requires --insert", + ).Bool(), } return parser, options @@ -91,6 +101,10 @@ func parseConfig(args []string) (app.Config, error) { files = nil } + if *options.noBackup && !*options.insert { + return app.Config{}, errors.New("--no-backup requires --insert") + } + return app.Config{ Files: files, Serial: *options.serial, @@ -109,6 +123,10 @@ func parseConfig(args []string) (app.Config, error) { Escape: !*options.noEscape, Indent: *options.indent, }, + Insert: app.InsertConfig{ + Enabled: *options.insert, + NoBackup: *options.noBackup, + }, Debug: *options.debug, }, nil } diff --git a/cmd/gh-md-toc/config_test.go b/cmd/gh-md-toc/config_test.go index 8d58e45..59c6520 100644 --- a/cmd/gh-md-toc/config_test.go +++ b/cmd/gh-md-toc/config_test.go @@ -200,3 +200,23 @@ func TestParseConfigStdinMarkerWithPaths(t *testing.T) { t.Errorf("got error %q, want it to mention the STDIN marker", err) } } + +func TestParseConfigInsertFlags(t *testing.T) { + cfg, err := parseConfig([]string{"--insert", "--no-backup", "README.md"}) + if err != nil { + t.Fatal(err) + } + if !cfg.Insert.Enabled || !cfg.Insert.NoBackup { + t.Errorf("got insert config %+v, want both flags set", cfg.Insert) + } +} + +func TestParseConfigNoBackupRequiresInsert(t *testing.T) { + _, err := parseConfig([]string{"--no-backup", "README.md"}) + if err == nil { + t.Fatal("got no error, want a usage error") + } + if !strings.Contains(err.Error(), "--no-backup requires --insert") { + t.Errorf("got error %q, want it to explain the dependency", err) + } +} diff --git a/cmd/gh-md-toc/main.go b/cmd/gh-md-toc/main.go index cb5263c..f67a51c 100644 --- a/cmd/gh-md-toc/main.go +++ b/cmd/gh-md-toc/main.go @@ -30,7 +30,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { return 1 } - application, err := app.New(cfg) + application, err := app.New(cfg, stderr) if err != nil { _, _ = fmt.Fprintln(stderr, err) return 1 diff --git a/e2e-tests/insert-src.md b/e2e-tests/insert-src.md new file mode 100644 index 0000000..e96188b --- /dev/null +++ b/e2e-tests/insert-src.md @@ -0,0 +1,8 @@ +# Title + + + + +## Section one + +## Section two diff --git a/e2e-tests/want-insert.md b/e2e-tests/want-insert.md new file mode 100644 index 0000000..9698b99 --- /dev/null +++ b/e2e-tests/want-insert.md @@ -0,0 +1,11 @@ +# Title + + +* [Title](#title) + * [Section one](#section-one) + * [Section two](#section-two) + + +## Section one + +## Section two diff --git a/e2e-tests/want.md b/e2e-tests/want.md index b4dc9d5..9299c5d 100644 --- a/e2e-tests/want.md +++ b/e2e-tests/want.md @@ -16,6 +16,7 @@ Table of Contents * [Remote files](#remote-files) * [Multiple files](#multiple-files) * [Combo](#combo) + * [Insert into a file](#insert-into-a-file) * [Starting Depth](#starting-depth) * [Depth](#depth) * [No escape](#no-escape) diff --git a/e2e-tests/want3.md b/e2e-tests/want3.md index 4ecb19f..5b61577 100644 --- a/e2e-tests/want3.md +++ b/e2e-tests/want3.md @@ -12,6 +12,7 @@ * [Remote files](#remote-files) * [Multiple files](#multiple-files) * [Combo](#combo) + * [Insert into a file](#insert-into-a-file) * [Starting Depth](#starting-depth) * [Depth](#depth) * [No escape](#no-escape) diff --git a/internal/app/config.go b/internal/app/config.go index f14dd99..2b08417 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -9,6 +9,7 @@ type Config struct { Presentation PresentationConfig GitHub GitHubConfig TOC coretoc.Config + Insert InsertConfig Debug bool } @@ -24,3 +25,9 @@ type GitHubConfig struct { GHUrl string GHVersion string } + +// InsertConfig controls rewriting the TOC inside the source document. +type InsertConfig struct { + Enabled bool + NoBackup bool +} diff --git a/internal/app/new.go b/internal/app/new.go index 8162d83..42d0d8a 100644 --- a/internal/app/new.go +++ b/internal/app/new.go @@ -7,7 +7,9 @@ import ( "github.com/ekalinin/github-markdown-toc.go/v2/internal/adapters" "github.com/ekalinin/github-markdown-toc.go/v2/internal/controller" + "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/entity" coretoc "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/toc" + "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/usecase/insertmd" "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/usecase/localmd" "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/usecase/remotehtml" "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/usecase/remotemd" @@ -17,13 +19,23 @@ type Controller interface { Process(ctx context.Context, stdout io.Writer) error } +type useCase interface { + Do(ctx context.Context, file string) (entity.Toc, error) +} + +type notifier interface { + Notify(format string, args ...any) +} + type App struct { - cfg Config - ctl Controller + cfg Config + ctl Controller + notify notifier } -func New(cfg Config) (*App, error) { +func New(cfg Config, stderr io.Writer) (*App, error) { log := adapters.NewLogger(cfg.Debug) + notify := adapters.NewNotifier(stderr) log.Info( "App.New: init configs ...", @@ -37,6 +49,8 @@ func New(cfg Config) (*App, error) { "indent", cfg.TOC.Indent, "github-version", cfg.GitHub.GHVersion, "token-configured", cfg.GitHub.GHToken != "", + "insert", cfg.Insert.Enabled, + "no-backup", cfg.Insert.NoBackup, ) ctlCfg := controller.Config{Files: cfg.Files, Serial: cfg.Serial} @@ -65,18 +79,34 @@ func New(cfg Config) (*App, error) { grabberJSON := coretoc.NewGenerator(jsonExtractor, renderer) getter := adapters.NewRemoteGetterWithClient(true, httpClient) temper := adapters.NewFileTemper() + reader := adapters.NewFileReader() + backupper := adapters.NewFileBackupper() + stamper := adapters.NewStamper() log.Info("App.New: init usecases ...") ucLocalMD := localmd.New(cfg.Debug, checker, writer, converter, grabberRe, log) + + var ucLocal useCase = ucLocalMD + if cfg.Insert.Enabled { + ucLocal = insertmd.New( + insertmd.Config{ + NoBackup: cfg.Insert.NoBackup, + HideFooter: cfg.Presentation.HideFooter, + }, + ucLocalMD, reader, writer, backupper, stamper, notify, log, + ) + } + ucRemoteMD := remotemd.New(getter, ucLocalMD, temper, log) ucRemoteHTML := remotehtml.New(cfg.Debug, getter, temper, grabberJSON, log) log.Info("App.New: init controller ...") - ctl := controller.New(ctlCfg, ucLocalMD, ucRemoteMD, ucRemoteHTML, log) + ctl := controller.New(ctlCfg, ucLocal, ucRemoteMD, ucRemoteHTML, log) log.Info("App.New: done.") return &App{ - ctl: ctl, - cfg: cfg, + ctl: ctl, + cfg: cfg, + notify: notify, }, nil } diff --git a/internal/app/new_test.go b/internal/app/new_test.go index f26ea97..16b9a0f 100644 --- a/internal/app/new_test.go +++ b/internal/app/new_test.go @@ -2,6 +2,7 @@ package app import ( "bytes" + "io" "log/slog" "strings" "testing" @@ -25,7 +26,7 @@ func TestNewDoesNotLogGitHubToken(t *testing.T) { GHUrl: "https://api.github.com", GHVersion: version.GH_2024_03, }, - }) + }, io.Discard) if err != nil { t.Fatal(err) } @@ -40,7 +41,7 @@ func TestNewDoesNotLogGitHubToken(t *testing.T) { } func TestNewRejectsUnknownRegexpVersion(t *testing.T) { - _, err := New(Config{GitHub: GitHubConfig{GHVersion: "unknown"}}) + _, err := New(Config{GitHub: GitHubConfig{GHVersion: "unknown"}}, io.Discard) if err == nil { t.Fatal("expected an error for an unsupported regexp version") } diff --git a/internal/app/run.go b/internal/app/run.go index a3cc2c9..5e70097 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -4,9 +4,18 @@ import ( "context" "fmt" "io" + + "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/entity" ) func (a *App) Run(ctx context.Context, stdout io.Writer) error { + if a.cfg.Insert.Enabled { + for _, file := range a.cfg.Files { + if entity.GetType(file) != entity.TypeLocalMD { + a.notify.Notify("!! %q is not a local file, can't insert the TOC into it", file) + } + } + } // do not show for stdin case (Files is empty) if !a.cfg.Presentation.HideHeader && len(a.cfg.Files) == 1 { diff --git a/internal/app/run_test.go b/internal/app/run_test.go index 651df52..8573329 100644 --- a/internal/app/run_test.go +++ b/internal/app/run_test.go @@ -8,6 +8,8 @@ import ( "io" "strings" "testing" + + "github.com/ekalinin/github-markdown-toc.go/v2/internal/version" ) type TestController struct { @@ -66,3 +68,24 @@ func Test_AppRunFail(t *testing.T) { t.Errorf("successful partial output was lost: %q", b.String()) } } + +func TestRunWarnsAboutRemoteInputsWithInsert(t *testing.T) { + var stderr bytes.Buffer + cfg := Config{ + Files: []string{"https://github.com/ekalinin/envirius/blob/master/README.md"}, + Insert: InsertConfig{Enabled: true}, + GitHub: GitHubConfig{GHVersion: version.GH_2024_03}, + } + application, err := New(cfg, &stderr) + if err != nil { + t.Fatal(err) + } + application.ctl = TestController{} + + if err := application.Run(context.Background(), &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + if !strings.Contains(stderr.String(), "is not a local file") { + t.Errorf("got stderr %q, want the not-a-local-file warning", stderr.String()) + } +} From c381339ea26bdd6a92ca3b52cae453fc3feff9aa Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 15:27:32 +0300 Subject: [PATCH 18/24] feat(skip-header): add the skipheader use case --- .../core/usecase/skipheader/skipheader.go | 100 ++++++++++++++++ .../usecase/skipheader/skipheader_test.go | 113 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 internal/core/usecase/skipheader/skipheader.go create mode 100644 internal/core/usecase/skipheader/skipheader_test.go diff --git a/internal/core/usecase/skipheader/skipheader.go b/internal/core/usecase/skipheader/skipheader.go new file mode 100644 index 0000000..84a780b --- /dev/null +++ b/internal/core/usecase/skipheader/skipheader.go @@ -0,0 +1,100 @@ +package skipheader + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/entity" +) + +type markdownProcessor interface { + DoAs(context.Context, string, string) (entity.Toc, error) +} + +type fileReader interface { + Read(context.Context, string) ([]byte, error) +} + +type fileTemper interface { + CreateTemp(context.Context, string, string) (*os.File, error) + Remove(string) error +} + +type logger interface { + Info(string, ...any) +} + +// - cut everything up to and including +// - hand the trimmed copy to the inner use case, keeping the original display path +type SkipHeader struct { + inner markdownProcessor + reader fileReader + temper fileTemper + log logger +} + +func New(inner markdownProcessor, reader fileReader, temper fileTemper, log logger) *SkipHeader { + return &SkipHeader{inner: inner, reader: reader, temper: temper, log: log} +} + +// trimToEndMarker drops every line up to and including the marker. The +// second result reports whether the marker was there at all. +func trimToEndMarker(content []byte) ([]byte, bool) { + lines := strings.Split(string(content), "\n") + for i, line := range lines { + if strings.TrimSpace(line) == entity.MarkerEnd { + return []byte(strings.Join(lines[i+1:], "\n")), true + } + } + return nil, false +} + +func (uc *SkipHeader) Do(ctx context.Context, file string) (entity.Toc, error) { + return uc.DoAs(ctx, file, file) +} + +func (uc *SkipHeader) DoAs(ctx context.Context, file, displayPath string) (toc entity.Toc, err error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("skip header of %q: %w", file, err) + } + + content, err := uc.reader.Read(ctx, file) + if err != nil { + return nil, fmt.Errorf("read %q to skip its header: %w", file, err) + } + + trimmed, found := trimToEndMarker(content) + if !found { + uc.log.Info("SkipHeader: no end marker, using the file as is", "file", file) + return uc.inner.DoAs(ctx, file, displayPath) + } + + tmpfile, err := uc.temper.CreateTemp(ctx, "", "ghtoc-skip-header-*.md") + if err != nil { + return nil, fmt.Errorf("create temporary file for %q: %w", file, err) + } + tempPath := tmpfile.Name() + defer func() { + if removeErr := uc.temper.Remove(tempPath); removeErr != nil { + err = errors.Join(err, fmt.Errorf("remove temporary file for %q: %w", file, removeErr)) + } + }() + + written, writeErr := tmpfile.Write(trimmed) + closeErr := tmpfile.Close() + switch { + case writeErr != nil: + return nil, fmt.Errorf("write temporary file for %q: %w", file, writeErr) + case written != len(trimmed): + return nil, fmt.Errorf("write temporary file for %q: %w", file, io.ErrShortWrite) + case closeErr != nil: + return nil, fmt.Errorf("close temporary file for %q: %w", file, closeErr) + } + + uc.log.Info("SkipHeader: using the trimmed copy", "path", tempPath) + return uc.inner.DoAs(ctx, tempPath, displayPath) +} diff --git a/internal/core/usecase/skipheader/skipheader_test.go b/internal/core/usecase/skipheader/skipheader_test.go new file mode 100644 index 0000000..9bbc09b --- /dev/null +++ b/internal/core/usecase/skipheader/skipheader_test.go @@ -0,0 +1,113 @@ +package skipheader + +import ( + "context" + "os" + "testing" + + "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/entity" +) + +type innerSpy struct { + gotFile string + gotDisplayPath string + gotContent string +} + +func (s *innerSpy) DoAs(_ context.Context, file, displayPath string) (entity.Toc, error) { + s.gotFile = file + s.gotDisplayPath = displayPath + if data, err := os.ReadFile(file); err == nil { + s.gotContent = string(data) + } + return entity.Toc{"* [Section](#section)"}, nil +} + +type readerStub struct{ data []byte } + +func (s readerStub) Read(context.Context, string) ([]byte, error) { return s.data, nil } + +// temperStub keeps this core test free of the adapters package. +type temperStub struct{} + +func (temperStub) CreateTemp(_ context.Context, dir, pattern string) (*os.File, error) { + return os.CreateTemp(dir, pattern) +} + +func (temperStub) Remove(path string) error { return os.Remove(path) } + +type loggerStub struct{} + +func (loggerStub) Info(string, ...any) {} + +func TestTrimToEndMarker(t *testing.T) { + tests := []struct { + name string + content string + want string + found bool + }{ + { + name: "drops the existing TOC block", + content: "# Title\n\n* [Title](#title)\n\n\n## Section\n", + want: "\n## Section\n", + found: true, + }, + { + name: "tolerates indentation", + content: " \n## Section\n", + want: "## Section\n", + found: true, + }, + { + name: "no marker", + content: "# Title\n## Section\n", + found: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, found := trimToEndMarker([]byte(tt.content)) + if found != tt.found { + t.Fatalf("got found=%v, want %v", found, tt.found) + } + if found && string(got) != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestSkipHeaderPassesTrimmedCopyDown(t *testing.T) { + content := "# Title\n\n* [Title](#title)\n\n\n## Section\n" + inner := &innerSpy{} + uc := New(inner, readerStub{data: []byte(content)}, temperStub{}, loggerStub{}) + + if _, err := uc.Do(context.Background(), "README.md"); err != nil { + t.Fatal(err) + } + if inner.gotContent != "\n## Section\n" { + t.Errorf("got inner content %q, want the trimmed document", inner.gotContent) + } + if inner.gotDisplayPath != "README.md" { + t.Errorf("got display path %q, want the original file", inner.gotDisplayPath) + } + if _, err := os.Stat(inner.gotFile); !os.IsNotExist(err) { + t.Errorf("got temporary file %q still on disk, want it removed", inner.gotFile) + } +} + +func TestSkipHeaderWithoutMarkerUsesTheFileAsIs(t *testing.T) { + inner := &innerSpy{} + uc := New(inner, readerStub{data: []byte("# Title\n")}, temperStub{}, loggerStub{}) + + if _, err := uc.DoAs(context.Background(), "README.md", "https://example.com/README.md"); err != nil { + t.Fatal(err) + } + if inner.gotFile != "README.md" { + t.Errorf("got inner file %q, want the original path", inner.gotFile) + } + if inner.gotDisplayPath != "https://example.com/README.md" { + t.Errorf("got display path %q, want it forwarded unchanged", inner.gotDisplayPath) + } +} From 85dc113e1de66df132aeae2b0ad8f6ef354326d6 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 15:53:05 +0300 Subject: [PATCH 19/24] test(skip-header): add cleanup mechanism tests --- .../usecase/skipheader/skipheader_test.go | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/internal/core/usecase/skipheader/skipheader_test.go b/internal/core/usecase/skipheader/skipheader_test.go index 9bbc09b..a8d2b26 100644 --- a/internal/core/usecase/skipheader/skipheader_test.go +++ b/internal/core/usecase/skipheader/skipheader_test.go @@ -2,6 +2,7 @@ package skipheader import ( "context" + "errors" "os" "testing" @@ -111,3 +112,66 @@ func TestSkipHeaderWithoutMarkerUsesTheFileAsIs(t *testing.T) { t.Errorf("got display path %q, want it forwarded unchanged", inner.gotDisplayPath) } } + +// failingRemoveTemper hands out real temp files but cannot remove them. +type failingRemoveTemper struct{ err error } + +func (t failingRemoveTemper) CreateTemp(_ context.Context, dir, pattern string) (*os.File, error) { + return os.CreateTemp(dir, pattern) +} + +func (t failingRemoveTemper) Remove(string) error { return t.err } + +func TestSkipHeaderReportsCleanupFailure(t *testing.T) { + removeErr := errors.New("remove failed") + inner := &innerSpy{} + uc := New(inner, readerStub{data: []byte("# Title\n\n\n## Section\n")}, + failingRemoveTemper{err: removeErr}, loggerStub{}) + + _, err := uc.Do(context.Background(), "README.md") + if inner.gotFile != "" { + t.Cleanup(func() { _ = os.Remove(inner.gotFile) }) + } + if !errors.Is(err, removeErr) { + t.Fatalf("got error %v, want it to carry the removal failure", err) + } + if inner.gotContent != "\n## Section\n" { + t.Errorf("got inner content %q, want the trimmed document - the inner call still ran", inner.gotContent) + } +} + +// closedFileTemper returns a temp file that is already closed, so writing to it fails. +type closedFileTemper struct{ removed *bool } + +func (t closedFileTemper) CreateTemp(_ context.Context, dir, pattern string) (*os.File, error) { + file, err := os.CreateTemp(dir, pattern) + if err != nil { + return nil, err + } + if err := file.Close(); err != nil { + return nil, err + } + return file, nil +} + +func (t closedFileTemper) Remove(path string) error { + *t.removed = true + return os.Remove(path) +} + +func TestSkipHeaderRemovesTempFileWhenTheWriteFails(t *testing.T) { + removed := false + inner := &innerSpy{} + uc := New(inner, readerStub{data: []byte("# Title\n\n## Section\n")}, + closedFileTemper{removed: &removed}, loggerStub{}) + + if _, err := uc.Do(context.Background(), "README.md"); err == nil { + t.Fatal("got no error, want the write to an already-closed file to fail") + } + if !removed { + t.Error("got no removal, want the temp file cleaned up after the write failed") + } + if inner.gotFile != "" { + t.Errorf("got the inner use case called with %q, want it never reached", inner.gotFile) + } +} From 188069ba64d6644c36b6e1ea808ed489aba70b24 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 16:17:19 +0300 Subject: [PATCH 20/24] feat(skip-header): add the --skip-header flag --- ARCHITECTURE.md | 133 ++++++++++++++++++++++++++++----------- README.md | 23 +++++++ cmd/gh-md-toc/config.go | 10 ++- e2e-tests/want.md | 1 + e2e-tests/want3.md | 1 + internal/app/config.go | 1 + internal/app/new.go | 20 ++++-- internal/app/new_test.go | 84 +++++++++++++++++++++++++ 8 files changed, 231 insertions(+), 42 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 76f0e28..185d240 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -42,8 +42,9 @@ cmd/gh-md-toc │ └── input routing, concurrency, and result output ├── internal/core/usecase │ ├── localmd - │ ├── insertmd (wraps localmd, only when --insert is set) - │ ├── remotemd + │ ├── skipheader (wraps localmd, only when --skip-header is set) + │ ├── insertmd (wraps localChain, only when --insert is set) + │ ├── remotemd (wraps localChain unconditionally) │ └── remotehtml ├── internal/core/toc │ ├── Generator @@ -125,7 +126,15 @@ flowchart TD RegexpGenerator --> LocalMd Logger --> LocalMd - LocalMd --> InsertMd + LocalMd --> SkipHeader + FileReader --> SkipHeader + FileTemper --> SkipHeader + Logger --> SkipHeader + + LocalMd -.->|"--skip-header not set"| LocalChain[localChain] + SkipHeader -.->|"--skip-header set"| LocalChain + + LocalChain --> InsertMd FileReader --> InsertMd FileWriter --> InsertMd FileBackupper --> InsertMd @@ -135,7 +144,7 @@ flowchart TD RemoteGetter --> RemoteMd FileTemper --> RemoteMd - LocalMd --> RemoteMd + LocalChain --> RemoteMd Logger --> RemoteMd RemoteGetter --> RemoteHTML @@ -143,7 +152,7 @@ flowchart TD JSONGenerator --> RemoteHTML Logger --> RemoteHTML - LocalMd -.->|"--insert not set"| Controller + LocalChain -.->|"--insert not set"| Controller InsertMd -.->|"--insert set"| Controller RemoteMd --> Controller RemoteHTML --> Controller @@ -153,12 +162,19 @@ flowchart TD The shared `http.Client` gives all remote operations the same timeout configuration. The shared `Renderer` gives both extraction paths the same TOC formatting behavior. -`InsertMd` wraps `LocalMd`, it does not replace it: `app.New` always builds the plain -`LocalMd` and only builds `InsertMd` around it when `cfg.Insert.Enabled` is true. The -controller then receives whichever of the two implements the local-file use case for -that run. `RemoteMd` always keeps a direct reference to the unwrapped `LocalMd`, -never to `InsertMd`, because it processes a downloaded temporary file and must never -have a TOC written back into it. +`app.New` always builds the plain `LocalMd`. When `cfg.SkipHeader` is true, it wraps +`LocalMd` in `SkipHeader`; either way, the result is assigned to the local variable +`localChain`. `LocalMd` and `SkipHeader` both satisfy the `markdownProcessor` +interface (`Do` and `DoAs`), so every consumer further down the chain works with +`localChain` without caring which of the two it actually holds. + +`InsertMd` wraps `localChain`, it does not replace it: `app.New` only builds +`InsertMd` around `localChain` when `cfg.Insert.Enabled` is true. The controller then +receives whichever of the two implements the local-file use case for that run. +`RemoteMd` always keeps a direct reference to `localChain`, never to `InsertMd`, +because it processes a downloaded temporary file and must never have a TOC written +back into it. `--skip-header` still applies to `RemoteMd`'s downloaded document +through `localChain`; only the temp-file-into-`InsertMd` combination is disallowed. ## Main types and responsibilities @@ -171,8 +187,9 @@ have a TOC written back into it. | `internal/app` | `InsertConfig` | Controls whether the TOC is written into the source document and whether a backup is kept. | | `internal/controller` | `Controller` | Selects a use case, runs document jobs, preserves output order, and aggregates errors. | | `internal/core/usecase/localmd` | `LocalMd` | Validates a local file, converts Markdown through GitHub, and generates a TOC from returned HTML. | -| `internal/core/usecase/insertmd` | `InsertMd` | Wraps `LocalMd`, then backs up and rewrites the block between the TOC markers in the source file. | -| `internal/core/usecase/remotemd` | `RemoteMd` | Downloads raw Markdown to a temporary file and delegates processing to `LocalMd`. | +| `internal/core/usecase/skipheader` | `SkipHeader` | Wraps `LocalMd` (or another `markdownProcessor`), cutting everything up to and including `` into a temporary file before delegating. | +| `internal/core/usecase/insertmd` | `InsertMd` | Wraps `localChain` (`LocalMd`, optionally wrapped by `SkipHeader`), then backs up and rewrites the block between the TOC markers in the source file. | +| `internal/core/usecase/remotemd` | `RemoteMd` | Downloads raw Markdown to a temporary file and delegates processing to `localChain`. | | `internal/core/usecase/remotehtml` | `RemoteHTML` | Downloads GitHub JSON data and generates a TOC through the JSON path. | | `internal/core/toc` | `Generator` | Combines a heading extractor with the shared renderer. | | `internal/core/toc` | `Renderer` | Applies depth, indentation, escaping, and link rules to headings. | @@ -201,16 +218,21 @@ Interfaces are intentionally small and located next to the consuming code. | Consumer | Interface | Implemented by | |---|---|---| | `app.App` | `app.Controller` | `*controller.Controller` | -| `app.App` | `app.useCase` (used by `app.New`, not stored on `App`) | `*localmd.LocalMd`, `*insertmd.InsertMd` | +| `app.App` | `app.useCase` (used by `app.New`, not stored on `App`) | `*localmd.LocalMd`, `*skipheader.SkipHeader`, `*insertmd.InsertMd` | +| `app.App` | `app.markdownProcessor` (used by `app.New` to type `localChain`) | `*localmd.LocalMd`, `*skipheader.SkipHeader` | | `app.App` | `app.notifier` | `*adapters.Notifier` | -| `controller.Controller` | `controller.useCase` | `*localmd.LocalMd`, `*insertmd.InsertMd`, `*remotemd.RemoteMd`, `*remotehtml.RemoteHTML` | +| `controller.Controller` | `controller.useCase` | `*localmd.LocalMd`, `*skipheader.SkipHeader`, `*insertmd.InsertMd`, `*remotemd.RemoteMd`, `*remotehtml.RemoteHTML` | | `controller.Controller` | `controller.logger` | `*adapters.Logger` | | `localmd.LocalMd` | `localmd.fileChecker` | `*adapters.FileChecker` | | `localmd.LocalMd` | `localmd.fileWriter` | `*adapters.FileWriter` | | `localmd.LocalMd` | `localmd.htmlConverter` | `*adapters.HTMLConverter` | | `localmd.LocalMd` | `localmd.tocGrabber` | `*toc.Generator` | | `localmd.LocalMd` | `localmd.logger` | `*adapters.Logger` | -| `insertmd.InsertMd` | `insertmd.useCase` | `*localmd.LocalMd` | +| `skipheader.SkipHeader` | `skipheader.markdownProcessor` | `*localmd.LocalMd` | +| `skipheader.SkipHeader` | `skipheader.fileReader` | `*adapters.FileReader` | +| `skipheader.SkipHeader` | `skipheader.fileTemper` | `*adapters.FileTemper` | +| `skipheader.SkipHeader` | `skipheader.logger` | `*adapters.Logger` | +| `insertmd.InsertMd` | `insertmd.useCase` | `*localmd.LocalMd`, `*skipheader.SkipHeader` (whichever `localChain` holds) | | `insertmd.InsertMd` | `insertmd.fileReader` | `*adapters.FileReader` | | `insertmd.InsertMd` | `insertmd.atomicWriter` | `*adapters.FileWriter` | | `insertmd.InsertMd` | `insertmd.fileBackupper` | `*adapters.FileBackupper` | @@ -218,7 +240,7 @@ Interfaces are intentionally small and located next to the consuming code. | `insertmd.InsertMd` | `insertmd.notifier` | `*adapters.Notifier` | | `insertmd.InsertMd` | `insertmd.logger` | `*adapters.Logger` | | `remotemd.RemoteMd` | `remotemd.remoteGetter` | `*adapters.RemoteGetter` | -| `remotemd.RemoteMd` | `remotemd.markdownProcessor` | `*localmd.LocalMd` (always the unwrapped use case, never `*insertmd.InsertMd`) | +| `remotemd.RemoteMd` | `remotemd.markdownProcessor` | `*localmd.LocalMd` or `*skipheader.SkipHeader` (always `localChain`, never `*insertmd.InsertMd`) | | `remotemd.RemoteMd` | `remotemd.fileTemper` | `*adapters.FileTemper` | | `remotemd.RemoteMd` | `remotemd.logger` | `*adapters.Logger` | | `remotehtml.RemoteHTML` | `remotehtml.remoteGetter` | `*adapters.RemoteGetter` | @@ -236,6 +258,7 @@ These interfaces allow unit tests to replace each dependency with a small stub w app.Config ├── Files []string ├── Serial bool +├── SkipHeader bool ├── Debug bool ├── Presentation app.PresentationConfig │ ├── HideHeader bool @@ -257,11 +280,16 @@ app.Config `cmd/gh-md-toc` maps flags and environment variables into this structure. `app.New` derives `TOC.AbsolutePaths` from whether the CLI received multiple file arguments, matching bash `gh-md-toc`, which drops the prefix when a single document is requested. +`SkipHeader` selects whether `app.New` wraps `LocalMd` in `SkipHeader` before +assigning the result to `localChain`; it takes no other parameters, since the +`` marker itself is the only input the use case needs. + Only the settings required at runtime are passed further: - controller receives `Files` and `Serial`; - `LocalMd` and `RemoteHTML` receive `Debug`; -- `RemoteMd` receives no configuration; +- `SkipHeader` and `RemoteMd` receive no configuration beyond the collaborators they + are built with; - `Renderer` receives `toc.Config`; - GitHub settings are used when constructing `HTMLConverter` and `RegexpExtractor`; - `InsertMd` receives `Insert.NoBackup` and `Presentation.HideFooter`; `app.Run` reads @@ -297,12 +325,41 @@ Controller When debug mode is enabled, `LocalMd` writes the returned HTML to `.debug.html` through `FileWriter`. +### Skip header (`--skip-header`) + +```text +Controller (or InsertMd, if --insert is also set) + -> SkipHeader.Do / DoAs + -> FileReader.Read + -> trim everything up to and including + -> FileTemper.CreateTemp + -> write the trimmed copy + -> LocalMd.DoAs (builds the TOC from the trimmed copy, as above) + -> FileTemper.Remove + -> entity.Toc +``` + +`SkipHeader` wraps `LocalMd` rather than replacing it: it reads the source file, +drops every line up to and including the `` marker, and writes what remains +to a temporary file. It then delegates to `LocalMd.DoAs` with the temporary file as +the path to read and the *original* file as the display path, so rendered links still +point at the source document. If the source file has no `` marker, `SkipHeader` +skips the trimming and delegates to the inner use case with the original file +unchanged. The temporary file is always removed afterward, whether or not the inner +call succeeded. + +`app.New` only builds `SkipHeader` when `cfg.SkipHeader` is true; otherwise +`localChain` is the plain `LocalMd`. Because `SkipHeader` implements the same +`markdownProcessor` interface as `LocalMd` (`Do` and `DoAs`), every consumer that +holds `localChain` - `InsertMd` and `RemoteMd` - works identically regardless of +which one it is. + ### Insert into the source file (`--insert`) ```text Controller -> InsertMd.Do - -> LocalMd.Do (builds the TOC, as above) + -> localChain.Do (builds the TOC; may go through SkipHeader first, as above) -> FileReader.Read -> replaceBetweenMarkers (validate and rewrite the / block) -> FileBackupper.Backup (skipped when --no-backup is set) @@ -311,23 +368,27 @@ Controller -> entity.Toc ``` -`InsertMd` wraps `LocalMd` rather than replacing it: it delegates to `LocalMd.Do` to -obtain the TOC, then reads the current file, validates that it has exactly one -`` / `` marker pair in order, and rewrites only the block between -them, byte for byte outside that block. The backup step runs before the rewrite so a -failed rewrite still leaves a pristine copy on disk; it is skipped when -`Insert.NoBackup` is set. `FileWriter.WriteAtomic` writes through a temporary file in -the same directory and renames it over the target, so a failed write cannot truncate -the original. `Notifier` reports the backup path and the rewritten path on stderr, -separate from the TOC printed to stdout. +`InsertMd` wraps `localChain` rather than replacing it: it delegates to +`localChain.Do` to obtain the TOC, then reads the *current, untrimmed* file, +validates that it has exactly one `` / `` marker pair in order, and +rewrites only the block between them, byte for byte outside that block. This is what +makes `--insert --skip-header` correct together: the TOC is built from the content +after the existing block (because `localChain` trimmed it away before building the +TOC), while the rewrite step still sees, and correctly replaces, the existing block +in the real file. The backup step runs before the rewrite so a failed rewrite still +leaves a pristine copy on disk; it is skipped when `Insert.NoBackup` is set. +`FileWriter.WriteAtomic` writes through a temporary file in the same directory and +renames it over the target, so a failed write cannot truncate the original. +`Notifier` reports the backup path and the rewritten path on stderr, separate from +the TOC printed to stdout. `app.New` only builds `InsertMd` when `cfg.Insert.Enabled` is true; otherwise the -controller receives the plain `LocalMd` for local files, unchanged from before this -use case existed. `RemoteMd` is wired with the unwrapped `LocalMd` unconditionally, so -downloading a remote document and then applying `--insert` to it never happens - the -temporary file `RemoteMd` creates cannot be the target of a rewrite. `app.Run` warns -on stderr, once per input, about any file passed alongside `--insert` that is not -`entity.TypeLocalMD`, and otherwise leaves the document unmodified. +controller receives the plain `localChain` for local files, unchanged from before +`InsertMd` existed. `RemoteMd` is wired with `localChain` unconditionally, never with +`InsertMd`, so downloading a remote document and then applying `--insert` to it never +happens - the temporary file `RemoteMd` creates cannot be the target of a rewrite. +`app.Run` warns on stderr, once per input, about any file passed alongside `--insert` +that is not `entity.TypeLocalMD`, and otherwise leaves the document unmodified. ### Remote raw Markdown @@ -337,12 +398,12 @@ Controller -> RemoteGetter.Get -> FileTemper.CreateTemp -> write downloaded Markdown - -> LocalMd.DoAs + -> localChain.DoAs -> remove temporary file -> entity.Toc ``` -`RemoteMd` reuses the complete local Markdown workflow after downloading the document. It validates the response media type as `text/plain` before creating the temporary file. It calls `LocalMd.DoAs` with the temporary file path and the original URL as the display path, so rendered links point at the source document instead of the temporary file. +`RemoteMd` reuses the complete local Markdown workflow after downloading the document. It validates the response media type as `text/plain` before creating the temporary file. It calls `localChain.DoAs` with the temporary file path and the original URL as the display path, so rendered links point at the source document instead of the temporary file. When `--skip-header` is set, `localChain` is `SkipHeader`, so the downloaded document is trimmed the same way a local file would be - skipping a header in a downloaded document is correct, since the document itself is never rewritten. ### GitHub document page diff --git a/README.md b/README.md index 3142ab2..c18f565 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Table of Contents * [Multiple files](#multiple-files) * [Combo](#combo) * [Insert into a file](#insert-into-a-file) + * [Skip header](#skip-header) * [Starting Depth](#starting-depth) * [Depth](#depth) * [No Escape](#no-escape) @@ -104,6 +105,7 @@ Flags: RegExp version. Default: 2024-03 --insert Insert the TOC into the file, between and . Local files only --no-backup Do not keep a backup copy of the file. Requires --insert + --skip-header Ignore everything up to when building the TOC --version Show application version. Args: @@ -341,6 +343,27 @@ ran the command, and when) are written right after the TOC, inside the markers. Status messages - the backup path, or a warning about a non-local input - are printed to stderr, not stdout. +Skip header +----------- + +Use `--skip-header` to make `gh-md-toc` ignore everything up to and including the +end marker (``) when building the TOC. Only the content after that marker +is scanned for headings. + +```bash +$ ./gh-md-toc --skip-header README.md +``` + +This matters when combined with `--insert`: after a first `--insert` run, the file +already has a TOC block written between the markers. Re-running `--insert` without +`--skip-header` scans that block, and anything above it, together with the rest of +the document. Adding `--skip-header` makes sure only the content after the existing +block feeds the new TOC, so re-running `--insert` repeatedly does not fold the old +TOC's own entries back into itself. + +`--skip-header` has no effect on documents that don't contain an end marker; the +whole document is scanned, exactly as without the flag. + Starting Depth -------------- diff --git a/cmd/gh-md-toc/config.go b/cmd/gh-md-toc/config.go index 2a87c91..e2bfdc6 100644 --- a/cmd/gh-md-toc/config.go +++ b/cmd/gh-md-toc/config.go @@ -31,6 +31,7 @@ type cliOptions struct { reVersion *string insert *bool noBackup *bool + skipHeader *bool } func newCLI() (*kingpin.Application, cliOptions) { @@ -65,6 +66,10 @@ func newCLI() (*kingpin.Application, cliOptions) { "no-backup", "Do not keep a backup copy of the file. Requires --insert", ).Bool(), + skipHeader: parser.Flag( + "skip-header", + "Ignore everything up to when building the TOC", + ).Bool(), } return parser, options @@ -106,8 +111,9 @@ func parseConfig(args []string) (app.Config, error) { } return app.Config{ - Files: files, - Serial: *options.serial, + Files: files, + Serial: *options.serial, + SkipHeader: *options.skipHeader, Presentation: app.PresentationConfig{ HideHeader: *options.hideHeader, HideFooter: *options.hideFooter, diff --git a/e2e-tests/want.md b/e2e-tests/want.md index 9299c5d..4659f1b 100644 --- a/e2e-tests/want.md +++ b/e2e-tests/want.md @@ -17,6 +17,7 @@ Table of Contents * [Multiple files](#multiple-files) * [Combo](#combo) * [Insert into a file](#insert-into-a-file) + * [Skip header](#skip-header) * [Starting Depth](#starting-depth) * [Depth](#depth) * [No escape](#no-escape) diff --git a/e2e-tests/want3.md b/e2e-tests/want3.md index 5b61577..4a313da 100644 --- a/e2e-tests/want3.md +++ b/e2e-tests/want3.md @@ -13,6 +13,7 @@ * [Multiple files](#multiple-files) * [Combo](#combo) * [Insert into a file](#insert-into-a-file) + * [Skip header](#skip-header) * [Starting Depth](#starting-depth) * [Depth](#depth) * [No escape](#no-escape) diff --git a/internal/app/config.go b/internal/app/config.go index 2b08417..8e57dc8 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -6,6 +6,7 @@ import coretoc "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/toc" type Config struct { Files []string Serial bool + SkipHeader bool Presentation PresentationConfig GitHub GitHubConfig TOC coretoc.Config diff --git a/internal/app/new.go b/internal/app/new.go index 42d0d8a..d679d4e 100644 --- a/internal/app/new.go +++ b/internal/app/new.go @@ -13,6 +13,7 @@ import ( "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/usecase/localmd" "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/usecase/remotehtml" "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/usecase/remotemd" + "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/usecase/skipheader" ) type Controller interface { @@ -23,6 +24,13 @@ type useCase interface { Do(ctx context.Context, file string) (entity.Toc, error) } +// markdownProcessor is the local-document pipeline: the plain use case, or one +// wrapped by skipheader. Both forms carry the display path through DoAs. +type markdownProcessor interface { + Do(ctx context.Context, file string) (entity.Toc, error) + DoAs(ctx context.Context, file, displayPath string) (entity.Toc, error) +} + type notifier interface { Notify(format string, args ...any) } @@ -51,6 +59,7 @@ func New(cfg Config, stderr io.Writer) (*App, error) { "token-configured", cfg.GitHub.GHToken != "", "insert", cfg.Insert.Enabled, "no-backup", cfg.Insert.NoBackup, + "skip-header", cfg.SkipHeader, ) ctlCfg := controller.Config{Files: cfg.Files, Serial: cfg.Serial} @@ -84,20 +93,23 @@ func New(cfg Config, stderr io.Writer) (*App, error) { stamper := adapters.NewStamper() log.Info("App.New: init usecases ...") - ucLocalMD := localmd.New(cfg.Debug, checker, writer, converter, grabberRe, log) + var localChain markdownProcessor = localmd.New(cfg.Debug, checker, writer, converter, grabberRe, log) + if cfg.SkipHeader { + localChain = skipheader.New(localChain, reader, temper, log) + } - var ucLocal useCase = ucLocalMD + var ucLocal useCase = localChain if cfg.Insert.Enabled { ucLocal = insertmd.New( insertmd.Config{ NoBackup: cfg.Insert.NoBackup, HideFooter: cfg.Presentation.HideFooter, }, - ucLocalMD, reader, writer, backupper, stamper, notify, log, + localChain, reader, writer, backupper, stamper, notify, log, ) } - ucRemoteMD := remotemd.New(getter, ucLocalMD, temper, log) + ucRemoteMD := remotemd.New(getter, localChain, temper, log) ucRemoteHTML := remotehtml.New(cfg.Debug, getter, temper, grabberJSON, log) log.Info("App.New: init controller ...") diff --git a/internal/app/new_test.go b/internal/app/new_test.go index 16b9a0f..c1b6f9e 100644 --- a/internal/app/new_test.go +++ b/internal/app/new_test.go @@ -2,11 +2,17 @@ package app import ( "bytes" + "context" "io" "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "strings" "testing" + coretoc "github.com/ekalinin/github-markdown-toc.go/v2/internal/core/toc" "github.com/ekalinin/github-markdown-toc.go/v2/internal/version" ) @@ -51,3 +57,81 @@ func TestNewRejectsUnknownRegexpVersion(t *testing.T) { t.Errorf("got error %q, want it to contain %q", err, want) } } + +func TestNewSkipHeaderTrimsTheDocumentSentToGitHub(t *testing.T) { + var gotBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + gotBody = string(body) + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`

Section

`)) + })) + defer server.Close() + + dir := t.TempDir() + file := filepath.Join(dir, "README.md") + content := "# Old Title\n\n* [Old Title](#old-title)\n\n\n## Section\n" + if err := os.WriteFile(file, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + application, err := New(Config{ + Files: []string{file}, + SkipHeader: true, + GitHub: GitHubConfig{GHUrl: server.URL, GHVersion: version.GH_2024_03}, + TOC: coretoc.DefaultConfig(), + }, io.Discard) + if err != nil { + t.Fatal(err) + } + if err := application.Run(context.Background(), &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + + if strings.Contains(gotBody, "Old Title") { + t.Errorf("got request body %q, want everything up to dropped", gotBody) + } + if !strings.Contains(gotBody, "## Section") { + t.Errorf("got request body %q, want the content after kept", gotBody) + } +} + +func TestNewWithoutSkipHeaderSendsTheWholeDocument(t *testing.T) { + var gotBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + gotBody = string(body) + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`

Section

`)) + })) + defer server.Close() + + dir := t.TempDir() + file := filepath.Join(dir, "README.md") + content := "# Old Title\n\n* [Old Title](#old-title)\n\n\n## Section\n" + if err := os.WriteFile(file, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + application, err := New(Config{ + Files: []string{file}, + GitHub: GitHubConfig{GHUrl: server.URL, GHVersion: version.GH_2024_03}, + TOC: coretoc.DefaultConfig(), + }, io.Discard) + if err != nil { + t.Fatal(err) + } + if err := application.Run(context.Background(), &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + + if !strings.Contains(gotBody, "Old Title") { + t.Errorf("got request body %q, want the untouched document", gotBody) + } +} From 8bb56102dabf7bbb2757bd5ad6134b1caffd17d7 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 16:21:07 +0300 Subject: [PATCH 21/24] docs(skip-header): correct the README rationale for --skip-header --- README.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c18f565..3ad5101 100644 --- a/README.md +++ b/README.md @@ -354,12 +354,15 @@ is scanned for headings. $ ./gh-md-toc --skip-header README.md ``` -This matters when combined with `--insert`: after a first `--insert` run, the file -already has a TOC block written between the markers. Re-running `--insert` without -`--skip-header` scans that block, and anything above it, together with the rest of -the document. Adding `--skip-header` makes sure only the content after the existing -block feeds the new TOC, so re-running `--insert` repeatedly does not fold the old -TOC's own entries back into itself. +The point is to hide the topmost headlines - the document's own title, and any +other heading placed above the marker block, are excluded from the generated TOC. +Without `--skip-header`, a document's title heading gets picked up like any other +heading and shows up as an entry in its own TOC. + +This matters when combined with `--insert`: place the markers right below your +title, e.g. `# Project` followed by ``/``, and add `--skip-header` +to keep `Project` from appearing as the first entry of the TOC sitting right under +it. `--skip-header` has no effect on documents that don't contain an end marker; the whole document is scanned, exactly as without the flag. From b247cdf32ccd7e178fed3befc9d743e04f4b1b3e Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Sat, 15 Aug 2026 13:28:55 +0300 Subject: [PATCH 22/24] fix(skip-header): write the debug dump next to the document --- CHANGELOG.md | 3 ++ internal/core/usecase/localmd/localmd.go | 9 +++++- internal/core/usecase/localmd/localmd_test.go | 32 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8759a3..4472534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,9 @@ CLI behaviour, not about the output format. ### Fixed +- `--debug` writes its HTML dump next to the document you named. With `--skip-header` + the dump used to be named after an internal temporary copy and was left behind in + the temp directory. - `GH_TOC_URL` is honoured again when `--github-url` is not passed. The flag's non-empty default used to shadow the environment variable, so the variable had no effect. ([#60](https://github.com/ekalinin/github-markdown-toc.go/pull/60)) diff --git a/internal/core/usecase/localmd/localmd.go b/internal/core/usecase/localmd/localmd.go index 99b5dcf..2babc17 100644 --- a/internal/core/usecase/localmd/localmd.go +++ b/internal/core/usecase/localmd/localmd.go @@ -83,7 +83,14 @@ func (uc *LocalMd) DoAs(ctx context.Context, file, displayPath string) (entity.T } if uc.debug { - htmlFile := file + ".debug.html" + // Name the dump after the document the user asked for. When the two differ, + // file is a temporary copy that its owner deletes, so a dump named after it + // would be stranded in the temp directory instead of next to the document. + debugTarget := file + if entity.GetType(displayPath) == entity.TypeLocalMD { + debugTarget = displayPath + } + htmlFile := debugTarget + ".debug.html" uc.log.Info("LocalMD: writing html", "file", htmlFile) // TODO: move to port if err := uc.writer.Write(ctx, htmlFile, []byte(html)); err != nil { diff --git a/internal/core/usecase/localmd/localmd_test.go b/internal/core/usecase/localmd/localmd_test.go index b93c2c3..e618c59 100644 --- a/internal/core/usecase/localmd/localmd_test.go +++ b/internal/core/usecase/localmd/localmd_test.go @@ -205,3 +205,35 @@ func TestLocalMdDoAsUsesDisplayPath(t *testing.T) { t.Errorf("got grabber path %q, want the display path", grabber.gotPath) } } + +func TestLocalMdDoAsWritesDebugNextToTheDisplayPath(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "README.md") + trimmed := filepath.Join(dir, "ghtoc-skip-header-123.md") + for _, path := range []string{source, trimmed} { + if err := os.WriteFile(path, []byte("# Title\n"), 0644); err != nil { + t.Fatal(err) + } + } + + uc := New(true, checkerStub{exists: true}, &adapterWriter{}, + converterStub{html: "

Title

"}, &grabberStub{toc: &entity.Toc{}}, loggerStub{}) + + if _, err := uc.DoAs(context.Background(), trimmed, source); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(source + ".debug.html"); err != nil { + t.Errorf("got no dump next to the document: %v", err) + } + if _, err := os.Stat(trimmed + ".debug.html"); !os.IsNotExist(err) { + t.Error("got a dump named after the temporary copy, want none") + } +} + +// adapterWriter writes through to disk so the test can assert on real files. +type adapterWriter struct{} + +func (adapterWriter) Write(_ context.Context, file string, data []byte) error { + return os.WriteFile(file, data, 0644) +} From 55a6db3dc3d6b51b3f477f1f8f3324402cbd7cb9 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 16:27:46 +0300 Subject: [PATCH 23/24] fix(github): explain rate limiting in the 403 and 429 errors --- README.md | 1 + internal/adapters/htmlconverter.go | 23 ++++++++++- internal/adapters/htmlconverter_test.go | 51 +++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3ad5101..c927fb9 100644 --- a/README.md +++ b/README.md @@ -416,6 +416,7 @@ No escape GitHub token ------------ +Without a GitHub token, the `/markdown/raw` endpoint allows very few requests per hour; when the rate limit is exceeded, the tool will suggest passing a token. All your tokents are [here](https://github.com/settings/tokens). Example for cli argument: diff --git a/internal/adapters/htmlconverter.go b/internal/adapters/htmlconverter.go index 79b3c10..ae790a9 100644 --- a/internal/adapters/htmlconverter.go +++ b/internal/adapters/htmlconverter.go @@ -2,6 +2,8 @@ package adapters import ( "context" + "errors" + "fmt" "net/http" ) @@ -37,5 +39,24 @@ func (c *HTMLConverter) Convert(ctx context.Context, file string) (string, error c.log.Info("adapters.HTMLConverter.Convert: start", "file", file) ghURL := c.ghURL + "/markdown/raw" c.log.Info("adapters.HTMLConverter.Convert: sending", "url", ghURL) - return c.poster.Post(ctx, ghURL, c.ghToken, file) + + html, err := c.poster.Post(ctx, ghURL, c.ghToken, file) + if err != nil { + return "", withRateLimitHint(err) + } + return html, nil +} + +// withRateLimitHint points the user at the token options when GitHub throttles us. +// Without a token the markdown endpoint allows very few requests per hour. +func withRateLimitHint(err error) error { + var statusErr *HTTPStatusError + if !errors.As(err, &statusErr) { + return err + } + if statusErr.StatusCode != http.StatusForbidden && + statusErr.StatusCode != http.StatusTooManyRequests { + return err + } + return fmt.Errorf("%w (GitHub API rate limit reached, pass --token or set GH_TOC_TOKEN)", err) } diff --git a/internal/adapters/htmlconverter_test.go b/internal/adapters/htmlconverter_test.go index a4b5e51..73f1191 100644 --- a/internal/adapters/htmlconverter_test.go +++ b/internal/adapters/htmlconverter_test.go @@ -3,6 +3,8 @@ package adapters import ( "context" "errors" + "net/http" + "strings" "testing" ) @@ -74,3 +76,52 @@ func Test_HTMLConverterX(t *testing.T) { t.Errorf("converter is not of type RemotePoster") } } + +type posterStub struct{ err error } + +func (s posterStub) Post(context.Context, string, string, string) (string, error) { + return "", s.err +} + +func TestHTMLConverterRateLimitHint(t *testing.T) { + tests := []struct { + name string + err error + wantHint bool + }{ + { + name: "forbidden", + err: &HTTPStatusError{StatusCode: http.StatusForbidden, Body: "API rate limit exceeded"}, + wantHint: true, + }, + { + name: "too many requests", + err: &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, + wantHint: true, + }, + { + name: "server error keeps the bare message", + err: &HTTPStatusError{StatusCode: http.StatusInternalServerError}, + wantHint: false, + }, + { + name: "transport error keeps the bare message", + err: errors.New("connection refused"), + wantHint: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + converter := NewHTMLConverterX("", "https://api.github.com", posterStub{err: tt.err}, NewLogger(false)) + + _, err := converter.Convert(context.Background(), "README.md") + if !errors.Is(err, tt.err) { + t.Fatalf("got error %v, want the original wrapped", err) + } + hasHint := strings.Contains(err.Error(), "GH_TOC_TOKEN") + if hasHint != tt.wantHint { + t.Errorf("got hint=%v in %q, want %v", hasHint, err, tt.wantHint) + } + }) + } +} From db7cdf22eda174d9fc5c0d3334c432925b270948 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 17:19:44 +0300 Subject: [PATCH 24/24] fix(github): check response body for actual rate limit errors --- README.md | 2 +- internal/adapters/htmlconverter.go | 20 ++++++++++++++++---- internal/adapters/htmlconverter_test.go | 5 +++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c927fb9..109d319 100644 --- a/README.md +++ b/README.md @@ -416,7 +416,7 @@ No escape GitHub token ------------ -Without a GitHub token, the `/markdown/raw` endpoint allows very few requests per hour; when the rate limit is exceeded, the tool will suggest passing a token. +Without a GitHub token, the `/markdown/raw` endpoint allows very few requests per hour; when the rate limit is exceeded, the tool will suggest passing a token via `--token`, `GH_TOC_TOKEN`, or `token.txt`. All your tokents are [here](https://github.com/settings/tokens). Example for cli argument: diff --git a/internal/adapters/htmlconverter.go b/internal/adapters/htmlconverter.go index ae790a9..8d83ff6 100644 --- a/internal/adapters/htmlconverter.go +++ b/internal/adapters/htmlconverter.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "strings" ) type remotePoster interface { @@ -47,16 +48,27 @@ func (c *HTMLConverter) Convert(ctx context.Context, file string) (string, error return html, nil } +// rateLimitMarker is what GitHub puts in the body when the API rate limit is hit. +// The bash gh-md-toc greps the response for the same string. +const rateLimitMarker = "API rate limit exceeded" + // withRateLimitHint points the user at the token options when GitHub throttles us. -// Without a token the markdown endpoint allows very few requests per hour. +// A 403 on its own is not enough: GitHub also returns it for bad credentials and for +// a token missing a scope, and sending those users off to fetch a token would be +// misleading. A 429 is unambiguous. func withRateLimitHint(err error) error { var statusErr *HTTPStatusError if !errors.As(err, &statusErr) { return err } - if statusErr.StatusCode != http.StatusForbidden && - statusErr.StatusCode != http.StatusTooManyRequests { + + rateLimited := statusErr.StatusCode == http.StatusTooManyRequests || + (statusErr.StatusCode == http.StatusForbidden && + strings.Contains(statusErr.Body, rateLimitMarker)) + if !rateLimited { return err } - return fmt.Errorf("%w (GitHub API rate limit reached, pass --token or set GH_TOC_TOKEN)", err) + return fmt.Errorf( + "%w (GitHub API rate limit reached, pass --token, set GH_TOC_TOKEN, "+ + "or put the token in token.txt next to the binary)", err) } diff --git a/internal/adapters/htmlconverter_test.go b/internal/adapters/htmlconverter_test.go index 73f1191..7985b8e 100644 --- a/internal/adapters/htmlconverter_test.go +++ b/internal/adapters/htmlconverter_test.go @@ -99,6 +99,11 @@ func TestHTMLConverterRateLimitHint(t *testing.T) { err: &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, wantHint: true, }, + { + name: "forbidden for a reason other than the rate limit", + err: &HTTPStatusError{StatusCode: http.StatusForbidden, Body: `{"message":"Bad credentials"}`}, + wantHint: false, + }, { name: "server error keeps the bare message", err: &HTTPStatusError{StatusCode: http.StatusInternalServerError},