diff --git a/internal/links/links.go b/internal/links/links.go index a8631822..f37d843f 100644 --- a/internal/links/links.go +++ b/internal/links/links.go @@ -17,6 +17,30 @@ func ReplaceTitle(content, oldTitle, newTitle string) string { return replaceAll(content, old, new) } +// ReplaceTitleN is ReplaceTitle for a caller that has ALREADY counted the +// occurrences, and it exists to stop that work being done twice. +// +// strings.Replace with n < 0 counts the string itself before building the +// result. The document rename cascade counts first anyway — its size guard +// needs the number before it is willing to build anything — so letting +// Replace re-count adds a full pass over every linking document on the +// success path (codex round 13). +// +// n MUST be the count of `[[oldTitle]]` in this exact content. Passing a +// smaller number silently leaves later occurrences unrewritten, which is why +// this is a separate function rather than an optional parameter on the one +// above: the obligation is visible at the call site. +// +// No measured speedup is claimed. This removes one linear pass from a path +// that also allocates a full copy of the same content and issues a write per +// linker, so the saving is real but not obviously significant; it is here +// because doing the same work twice needs a reason and there was not one. +func ReplaceTitleN(content, oldTitle, newTitle string, n int) string { + old := "[[" + oldTitle + "]]" + new := "[[" + newTitle + "]]" + return strings.Replace(content, old, new, n) +} + // RewriteWikiTitle rewrites the four title-form wiki-link shapes that // resolve to an item titled `oldTitle` in collection `collSlug`, // substituting `newTitle` for the title portion and preserving any diff --git a/internal/links/replace_title_termination_test.go b/internal/links/replace_title_termination_test.go index 81850a0a..29784b35 100644 --- a/internal/links/replace_title_termination_test.go +++ b/internal/links/replace_title_termination_test.go @@ -71,3 +71,38 @@ func TestReplaceTitle_StillRewritesEveryOccurrence(t *testing.T) { t.Errorf("an occurrence survived: %q", got) } } + +// TestReplaceTitleN_MatchesReplaceTitleWhenGivenTheTrueCount pins the +// obligation ReplaceTitleN puts on its caller: given the real occurrence +// count, it must produce exactly what ReplaceTitle produces. +// +// The under-count leg is the counterfactual, and it is why the two are +// separate functions rather than one with an optional parameter — passing a +// number that is too small does not error, it silently leaves later +// occurrences unrewritten, which on the rename path means links left pointing +// at a title that no longer exists. +func TestReplaceTitleN_MatchesReplaceTitleWhenGivenTheTrueCount(t *testing.T) { + for _, tc := range []struct{ name, content, old, new string }{ + {"several occurrences", "a [[Old]] b [[Old]] c [[Old]] d", "Old", "New"}, + {"none", "nothing to see here", "Old", "New"}, + {"new embeds old", "x [[A]] y", "A", "A]] [[A"}, + {"shrinking", "[[LongOldTitle]] and [[LongOldTitle]]", "LongOldTitle", "n"}, + } { + t.Run(tc.name, func(t *testing.T) { + want := ReplaceTitle(tc.content, tc.old, tc.new) + n := strings.Count(tc.content, "[["+tc.old+"]]") + if got := ReplaceTitleN(tc.content, tc.old, tc.new, n); got != want { + t.Errorf("ReplaceTitleN with the true count %d:\n got: %q\nwant: %q", n, got, want) + } + + // Under-counting must visibly diverge, or the count is not + // load-bearing and this function has no contract worth stating. + if n > 1 { + if got := ReplaceTitleN(tc.content, tc.old, tc.new, n-1); got == want { + t.Errorf("ReplaceTitleN with a count one too low produced the correct result; " + + "the caller's obligation is not real, so the API is misleading") + } + } + }) + } +} diff --git a/internal/store/documents.go b/internal/store/documents.go index 6408f4b4..f7ce316e 100644 --- a/internal/store/documents.go +++ b/internal/store/documents.go @@ -687,7 +687,7 @@ func (s *Store) updateLinksInTx(tx *sql.Tx, workspaceID, oldTitle, newTitle stri return newRenameCascadeTooLargeError(newTitle, retained) } - du.rewritten = links.ReplaceTitle(du.read, oldTitle, newTitle) + du.rewritten = links.ReplaceTitleN(du.read, oldTitle, newTitle, int(occurrences)) updates = append(updates, du) } if err := rows.Err(); err != nil {