diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index af49a59..158ffc5 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -10,6 +10,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + packages: write steps: - name: Checkout uses: actions/checkout@v7 @@ -29,6 +30,19 @@ jobs: exit 1 fi + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 with: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index bac8b95..5bba65f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -16,6 +16,40 @@ builds: - amd64 - arm64 - arm +dockers: + - image_templates: + - "ghcr.io/ekalinin/github-markdown-toc.go:{{ .Version }}-amd64" + - "ghcr.io/ekalinin/github-markdown-toc.go:latest-amd64" + dockerfile: Dockerfile + use: buildx + goos: linux + goarch: amd64 + build_flag_templates: + - "--platform=linux/amd64" + - "--label=org.opencontainers.image.source=https://github.com/ekalinin/github-markdown-toc.go" + - "--label=org.opencontainers.image.version={{ .Version }}" + - image_templates: + - "ghcr.io/ekalinin/github-markdown-toc.go:{{ .Version }}-arm64" + - "ghcr.io/ekalinin/github-markdown-toc.go:latest-arm64" + dockerfile: Dockerfile + use: buildx + goos: linux + goarch: arm64 + build_flag_templates: + - "--platform=linux/arm64" + - "--label=org.opencontainers.image.source=https://github.com/ekalinin/github-markdown-toc.go" + - "--label=org.opencontainers.image.version={{ .Version }}" + +docker_manifests: + - name_template: "ghcr.io/ekalinin/github-markdown-toc.go:{{ .Version }}" + image_templates: + - "ghcr.io/ekalinin/github-markdown-toc.go:{{ .Version }}-amd64" + - "ghcr.io/ekalinin/github-markdown-toc.go:{{ .Version }}-arm64" + - name_template: "ghcr.io/ekalinin/github-markdown-toc.go:latest" + image_templates: + - "ghcr.io/ekalinin/github-markdown-toc.go:latest-amd64" + - "ghcr.io/ekalinin/github-markdown-toc.go:latest-arm64" + checksum: name_template: 'checksums.txt' snapshot: diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 185d240..b9f9385 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -278,7 +278,7 @@ app.Config └── 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. +`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. `InsertMd` overrides this per document rather than per run: it asks its inner use case for a TOC rendered against an empty display path, so a TOC written into a document links to itself with bare anchors, since GitHub resolves relative links against that document's own directory. Documents that are not inserted into, such as a remote URL passed in the same run, keep their prefix. `SkipHeader` selects whether `app.New` wraps `LocalMd` in `SkipHeader` before assigning the result to `localChain`; it takes no other parameters, since the diff --git a/CHANGELOG.md b/CHANGELOG.md index 9338cf7..3bdc96e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Planned release: 2.1.0. -The generated table of contents is byte-identical to 2.0.1. Everything below is about -CLI behaviour, not about the output format. +### Added + +- `--insert` writes the generated TOC directly into a document, replacing everything + between a `` and `` marker pair. A backup copy is kept next to the + file unless `--no-backup` is also passed. +- `--skip-header` ignores everything up to and including `` when building the + TOC, so a document's own title heading is not picked up as an entry. +- `-` is now accepted as an explicit marker for reading Markdown from STDIN. +- `token.txt`, read from next to the executable, is now the last fallback for a GitHub + token, after `--token` and `GH_TOC_TOKEN`. +- A Docker image is published to `ghcr.io/ekalinin/github-markdown-toc.go`. ### Security @@ -52,6 +61,17 @@ CLI behaviour, not about the output format. the same one that ships the binaries. ([#58](https://github.com/ekalinin/github-markdown-toc.go/pull/58), [#84](https://github.com/ekalinin/github-markdown-toc.go/pull/84)) +- Multi-document runs now prefix links with the document path, which is what the + "Multiple files" and "Combo" sections of the README always documented but the tool + never actually did. A TOC written into a file with `--insert` is the exception: it + always uses bare anchors, because GitHub resolves relative links against the + document's own directory. Other documents in the same run, such as a remote URL, + keep their prefix. +- `--version` now also reports the OS, architecture and Go version used to build the + binary. The bare version number stays on the first line, so scripts that parse it + keep working. +- `--hide-footer` gains a second meaning under `--insert`: it also suppresses the + signature comment written into the file, not just the printed footer. ### Fixed @@ -81,6 +101,10 @@ CLI behaviour, not about the output format. - `gopkg.in/alecthomas/kingpin.v2` updated from v2.2.4 to v2.2.6, and the indirect module graph was tidied. The CLI surface is unchanged. ([#68](https://github.com/ekalinin/github-markdown-toc.go/pull/68)) +- GitHub rate-limit responses now explain that a token raises the limit, instead of + surfacing a bare HTTP status. +- Remote Markdown documents now render links against their source URL instead of the + path of the temporary file they were downloaded to. ## [2.0.1] - 2026-04-03 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5f393d0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM gcr.io/distroless/static:nonroot + +COPY gh-md-toc /gh-md-toc + +ENTRYPOINT ["/gh-md-toc"] diff --git a/Makefile b/Makefile index 63ab20f..40b0e35 100644 --- a/Makefile +++ b/Makefile @@ -86,6 +86,12 @@ release-local: @goreleaser check @goreleaser release --snapshot --clean +# Same as release-local, but without the container images. Useful when no Docker +# daemon is available. +release-local-nodocker: + @goreleaser check + @goreleaser release --snapshot --clean --skip=docker + # Step 3: publish the tag, which triggers the goreleaser workflow. release-push: @git push origin ${TAG} diff --git a/README.md b/README.md index 6a74655..a98fb19 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Table of Contents * [Compiling from source](#compiling-from-source) * [Go Install](#go-install) * [Homebew (Mac only)](#homebew-mac-only) + * [Docker](#docker) * [Tests](#tests) * [Usage](#usage) * [STDIN](#stdin) @@ -65,7 +66,7 @@ See the releases page, "Downloads" section: For example: ```bash -$ wget https://github.com/ekalinin/github-markdown-toc.go/releases/download/1.1.0/gh-md-toc.linux.amd64.tgz +$ wget https://github.com/ekalinin/github-markdown-toc.go/releases/download/v2.0.1/gh-md-toc.linux.amd64.tgz $ tar xzvf gh-md-toc.linux.amd64.tgz gh-md-toc $ ./gh-md-toc --version @@ -129,6 +130,19 @@ Homebew (Mac only) $ brew install github-markdown-toc ``` +Docker +------ + +```bash +$ docker run --rm -v "$PWD:/data" -w /data \ + ghcr.io/ekalinin/github-markdown-toc.go:latest README.md +``` + +Pass a token with `-e GH_TOC_TOKEN=...` when you hit the GitHub API rate limit. + +The image runs as a non-root user, so `--insert` against a bind-mounted file fails +with a permission error unless you also pass `--user "$(id -u):$(id -g)"`. + Tests ===== @@ -332,6 +346,10 @@ 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. +An inserted TOC always links with bare anchors (`#section`), even when several files +are passed at once. GitHub resolves relative links against the document's own +directory, so prefixing the links with the document's path would break them. + 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. @@ -419,6 +437,11 @@ 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 via `--token`, `GH_TOC_TOKEN`, or `token.txt`. All your tokents are [here](https://github.com/settings/tokens). +The token is resolved in this order: the `--token` flag, then the `GH_TOC_TOKEN` +environment variable, then a `token.txt` file placed next to the executable. The file +is the last fallback and is only used when neither the flag nor the environment +variable is set. + Example for cli argument: ```bash diff --git a/cmd/gh-md-toc/config.go b/cmd/gh-md-toc/config.go index e2bfdc6..615949e 100644 --- a/cmd/gh-md-toc/config.go +++ b/cmd/gh-md-toc/config.go @@ -106,6 +106,10 @@ func parseConfig(args []string) (app.Config, error) { files = nil } + if *options.insert && len(files) == 0 { + return app.Config{}, errors.New("--insert requires at least one file path") + } + if *options.noBackup && !*options.insert { return app.Config{}, errors.New("--no-backup requires --insert") } diff --git a/cmd/gh-md-toc/config_test.go b/cmd/gh-md-toc/config_test.go index 59c6520..cbb5337 100644 --- a/cmd/gh-md-toc/config_test.go +++ b/cmd/gh-md-toc/config_test.go @@ -220,3 +220,23 @@ func TestParseConfigNoBackupRequiresInsert(t *testing.T) { t.Errorf("got error %q, want it to explain the dependency", err) } } + +func TestParseConfigInsertRequiresFilePath(t *testing.T) { + _, err := parseConfig([]string{"--insert"}) + if err == nil { + t.Fatal("got no error, want a usage error") + } + if !strings.Contains(err.Error(), "--insert requires at least one file path") { + t.Errorf("got error %q, want it to explain the dependency", err) + } +} + +func TestParseConfigInsertRejectsStdinMarker(t *testing.T) { + _, err := parseConfig([]string{"--insert", "-"}) + if err == nil { + t.Fatal("got no error, want a usage error") + } + if !strings.Contains(err.Error(), "--insert requires at least one file path") { + t.Errorf("got error %q, want it to explain the dependency", err) + } +} diff --git a/e2e-tests/want.md b/e2e-tests/want.md index 4659f1b..a73dbb4 100644 --- a/e2e-tests/want.md +++ b/e2e-tests/want.md @@ -9,6 +9,7 @@ Table of Contents * [Compiling from source](#compiling-from-source) * [Go Install](#go-install) * [Homebew (Mac only)](#homebew-mac-only) + * [Docker](#docker) * [Tests](#tests) * [Usage](#usage) * [STDIN](#stdin) diff --git a/e2e-tests/want3.md b/e2e-tests/want3.md index 4a313da..03cd94a 100644 --- a/e2e-tests/want3.md +++ b/e2e-tests/want3.md @@ -5,6 +5,7 @@ * [Compiling from source](#compiling-from-source) * [Go Install](#go-install) * [Homebew (Mac only)](#homebew-mac-only) + * [Docker](#docker) * [Tests](#tests) * [Usage](#usage) * [STDIN](#stdin) diff --git a/internal/adapters/filebackup.go b/internal/adapters/filebackup.go index b878a10..919630c 100644 --- a/internal/adapters/filebackup.go +++ b/internal/adapters/filebackup.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "time" ) @@ -33,6 +34,13 @@ func (b *FileBackupper) Backup(ctx context.Context, file string) (string, error) return "", err } + // Resolve a symlink to its target, so the backup lands next to the real document + // rather than next to the link. A path that cannot be resolved (e.g. it does not + // exist) is handled below exactly as before. + if resolved, resolveErr := filepath.EvalSymlinks(file); resolveErr == nil { + file = resolved + } + info, err := os.Stat(file) if err != nil { return "", err @@ -45,6 +53,9 @@ func (b *FileBackupper) Backup(ctx context.Context, file string) (string, error) backup := fmt.Sprintf("%s.orig.%s", file, b.now().Format(backupTimeLayout)) dst, err := os.OpenFile(backup, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm()) if err != nil { + if os.IsExist(err) { + return "", fmt.Errorf("backup %q already exists, refusing to overwrite it: %w", backup, err) + } return "", err } _, writeErr := dst.Write(data) diff --git a/internal/adapters/filebackup_test.go b/internal/adapters/filebackup_test.go index fc50ab3..6fdbd43 100644 --- a/internal/adapters/filebackup_test.go +++ b/internal/adapters/filebackup_test.go @@ -4,12 +4,20 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" ) func TestFileBackupperBackup(t *testing.T) { - dir := t.TempDir() + // Resolve the temp dir itself first: on macOS it lives under a symlink + // (/tmp -> /private/tmp), which would otherwise make "want" below diverge from + // the resolved path Backup now returns, for reasons unrelated to what this test + // is checking. + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } file := filepath.Join(dir, "README.md") if err := os.WriteFile(file, []byte("original\n"), 0640); err != nil { t.Fatal(err) @@ -41,6 +49,49 @@ func TestFileBackupperBackup(t *testing.T) { } } +func TestFileBackupperBackupFollowsSymlinks(t *testing.T) { + // Resolve the temp dir itself first: on macOS it lives under a symlink + // (/tmp -> /private/tmp), which would otherwise make "want" below diverge from + // the resolved path for reasons unrelated to what this test is checking. + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + real := filepath.Join(dir, "real.md") + if err := os.WriteFile(real, []byte("original\n"), 0644); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link.md") + if err := os.Symlink(real, link); 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(), link) + if err != nil { + t.Fatal(err) + } + + want := real + ".orig.2026-08-12_134506" + if got != want { + t.Errorf("got backup path %q, want %q next to the real file", got, want) + } + linkInfo, err := os.Lstat(link) + if err != nil { + t.Fatal(err) + } + if linkInfo.Mode()&os.ModeSymlink == 0 { + t.Errorf("got %q replaced with a regular file, want the symlink kept", link) + } + 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") + } +} + func TestFileBackupperMissingFile(t *testing.T) { dir := t.TempDir() @@ -66,9 +117,13 @@ func TestFileBackupperRefusesToOverwriteExistingBackup(t *testing.T) { t.Fatal(err) } - if _, err := backupper.Backup(context.Background(), file); err == nil { + _, err = backupper.Backup(context.Background(), file) + if err == nil { t.Fatal("got no error, want a refusal to overwrite the existing backup") } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("got error %q, want it to explain that the backup already exists", err) + } data, err := os.ReadFile(first) if err != nil { diff --git a/internal/adapters/filewriter.go b/internal/adapters/filewriter.go index dc20587..334bdbd 100644 --- a/internal/adapters/filewriter.go +++ b/internal/adapters/filewriter.go @@ -27,6 +27,14 @@ func (f *FileWriter) WriteAtomic(ctx context.Context, file string, data []byte) return err } + // Resolve a symlink to its target, so the rename below replaces the target + // document instead of dropping a regular file in place of the link. A path that + // cannot be resolved (e.g. it does not exist yet) is handled below exactly as + // before. + if resolved, resolveErr := filepath.EvalSymlinks(file); resolveErr == nil { + file = resolved + } + perm := os.FileMode(0644) if info, statErr := os.Stat(file); statErr == nil { perm = info.Mode().Perm() diff --git a/internal/adapters/filewriter_test.go b/internal/adapters/filewriter_test.go index df038e3..b35cafa 100644 --- a/internal/adapters/filewriter_test.go +++ b/internal/adapters/filewriter_test.go @@ -92,6 +92,37 @@ func TestFileWriterWriteAtomicFailsWithoutDirectory(t *testing.T) { } } +func TestFileWriterWriteAtomicFollowsSymlinks(t *testing.T) { + dir := t.TempDir() + real := filepath.Join(dir, "real.md") + if err := os.WriteFile(real, []byte("old\n"), 0644); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link.md") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + + if err := NewFileWriter().WriteAtomic(context.Background(), link, []byte("new\n")); err != nil { + t.Fatal(err) + } + + info, err := os.Lstat(link) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Errorf("got %q replaced with a regular file, want the symlink kept", link) + } + data, err := os.ReadFile(real) + if err != nil { + t.Fatal(err) + } + if string(data) != "new\n" { + t.Errorf("got real file content %q, want %q", data, "new\n") + } +} + func TestFileWriterWriteAtomicRemovesTempFileOnFailure(t *testing.T) { dir := t.TempDir() // Renaming onto a directory fails, and by then the temp file exists. diff --git a/internal/adapters/notifier.go b/internal/adapters/notifier.go index d6c145f..7c2b35d 100644 --- a/internal/adapters/notifier.go +++ b/internal/adapters/notifier.go @@ -15,6 +15,9 @@ func NewNotifier(w io.Writer) *Notifier { return &Notifier{w: w} } +// Notify is called from up to eight worker goroutines with no synchronization of its +// own, so w must be safe for concurrent use. An *os.File satisfies this: its Write is +// internally locked. func (n *Notifier) Notify(format string, args ...any) { if n.w == nil { return diff --git a/internal/app/new.go b/internal/app/new.go index d679d4e..6909c2b 100644 --- a/internal/app/new.go +++ b/internal/app/new.go @@ -45,6 +45,16 @@ func New(cfg Config, stderr io.Writer) (*App, error) { log := adapters.NewLogger(cfg.Debug) notify := adapters.NewNotifier(stderr) + // Resolve the token.txt fallback before logging, so "token-configured" reflects + // the token that will actually be used, not just what --token/GH_TOC_TOKEN set. + 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 + } + log.Info( "App.New: init configs ...", "file-count", len(cfg.Files), @@ -64,13 +74,6 @@ func New(cfg Config, stderr io.Writer) (*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() @@ -82,6 +85,9 @@ func New(cfg Config, stderr io.Writer) (*App, error) { jsonExtractor := adapters.NewJSONExtractor() rendererCfg := cfg.TOC // bash gh-md-toc drops the path prefix only when a single document is requested. + // bash gh-md-toc drops the path prefix only when a single document is requested. + // InsertMd asks for bare anchors per document, so this stays a run-level rule and + // remote documents keep their URL prefix even when --insert is set. rendererCfg.AbsolutePaths = len(cfg.Files) > 1 renderer := coretoc.NewRenderer(rendererCfg) grabberRe := coretoc.NewGenerator(regexpExtractor, renderer) diff --git a/internal/app/new_test.go b/internal/app/new_test.go index c1b6f9e..afbbd8f 100644 --- a/internal/app/new_test.go +++ b/internal/app/new_test.go @@ -99,6 +99,51 @@ func TestNewSkipHeaderTrimsTheDocumentSentToGitHub(t *testing.T) { } } +func TestNewInsertMultipleFilesUsesBareAnchors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`

Section

` + + ``)) + })) + defer server.Close() + + dir := t.TempDir() + fileA := filepath.Join(dir, "a.md") + fileB := filepath.Join(dir, "b.md") + content := "# Title\n\n\n\n## Section\n" + for _, file := range []string{fileA, fileB} { + if err := os.WriteFile(file, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + + application, err := New(Config{ + Files: []string{fileA, fileB}, + GitHub: GitHubConfig{GHUrl: server.URL, GHVersion: version.GH_2024_03}, + TOC: coretoc.DefaultConfig(), + Insert: InsertConfig{Enabled: true, NoBackup: true}, + Presentation: PresentationConfig{HideFooter: true}, + }, io.Discard) + if err != nil { + t.Fatal(err) + } + if err := application.Run(context.Background(), &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(fileA) + if err != nil { + t.Fatal(err) + } + gotStr := string(got) + if !strings.Contains(gotStr, "](#") { + t.Errorf("got file content %q, want a bare anchor link into the document", gotStr) + } + if strings.Contains(gotStr, fileA) { + t.Errorf("got file content %q, want no reference to the document's own path", gotStr) + } +} + func TestNewWithoutSkipHeaderSendsTheWholeDocument(t *testing.T) { var gotBody string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/core/usecase/insertmd/insertmd.go b/internal/core/usecase/insertmd/insertmd.go index b330272..4682bc2 100644 --- a/internal/core/usecase/insertmd/insertmd.go +++ b/internal/core/usecase/insertmd/insertmd.go @@ -11,8 +11,12 @@ import ( // createdBy is the attribution written into the document, next to the TOC. const createdBy = "" +// useCase is the local-document pipeline InsertMd wraps. It takes a display path +// because InsertMd needs the TOC rendered with bare anchors: the TOC is written into +// the document itself, and GitHub resolves relative links against that document's own +// directory, so a path prefix there would point somewhere else. type useCase interface { - Do(context.Context, string) (entity.Toc, error) + DoAs(context.Context, string, string) (entity.Toc, error) } type fileReader interface { @@ -90,7 +94,7 @@ func (uc *InsertMd) Do(ctx context.Context, file string) (entity.Toc, error) { } uc.log.Info("InsertMD: start", "file", file) - toc, err := uc.inner.Do(ctx, file) + toc, err := uc.inner.DoAs(ctx, file, "") if err != nil { return nil, err } diff --git a/internal/core/usecase/insertmd/insertmd_test.go b/internal/core/usecase/insertmd/insertmd_test.go index b400696..ae6d656 100644 --- a/internal/core/usecase/insertmd/insertmd_test.go +++ b/internal/core/usecase/insertmd/insertmd_test.go @@ -11,11 +11,17 @@ import ( ) type innerStub struct { - toc entity.Toc - err error + toc entity.Toc + err error + gotDisplayPath *string } -func (s innerStub) Do(context.Context, string) (entity.Toc, error) { return s.toc, s.err } +func (s innerStub) DoAs(_ context.Context, _, displayPath string) (entity.Toc, error) { + if s.gotDisplayPath != nil { + *s.gotDisplayPath = displayPath + } + return s.toc, s.err +} type readerStub struct { data []byte @@ -200,3 +206,18 @@ func TestInsertMdWriteFailurePropagates(t *testing.T) { t.Errorf("got messages %v, want none - the insert notice must not claim a write that failed", notify.messages) } } + +func TestInsertMdAsksTheInnerUseCaseForBareAnchors(t *testing.T) { + var gotDisplayPath string + uc := New(Config{NoBackup: true}, + innerStub{toc: entity.Toc{"* [A](#a)"}, gotDisplayPath: &gotDisplayPath}, + readerStub{data: []byte("\n\n")}, &writerSpy{}, + &backupperSpy{}, stamperStub{}, ¬ifierSpy{}, loggerStub{}) + + if _, err := uc.Do(context.Background(), "README.md"); err != nil { + t.Fatal(err) + } + if gotDisplayPath != "" { + t.Errorf("got display path %q, want an empty one so the TOC links to the document itself", gotDisplayPath) + } +}