fix(text): truncate on rune boundaries, not byte offsets (#93)
Four call sites did `s[:limit] + "…"`. Both halves are byte operations: len() counts bytes and a slice cuts bytes. On Swedish text — å ä ö are two bytes, an em dash three — the cut lands mid-character and leaves an orphaned lead byte, producing invalid UTF-8. It failed loudly in exactly one place: wiki/index.md is written raw to disk, where every strict decoder then rejects the whole file. The other three (brain_context excerpts x2, brain_query excerpts) feed MCP responses, where encoding/json silently substitutes U+FFFD — so those had been quietly eating the last character of truncated excerpts, with no error anywhere and nothing to notice. textutil.Truncate counts runes and appends the marker only when content was actually dropped; an ellipsis on complete text is a lie about the content. The test is a property, not an example: it walks the limit across every offset of a multi-byte string and asserts utf8.Valid at each one, so a future off-by-one cannot pass by picking a lucky boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
// ingestion/internal/textutil/truncate_test.go
|
||||
package textutil_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/textutil"
|
||||
)
|
||||
|
||||
func TestTruncate_ShortStringsAreUntouched(t *testing.T) {
|
||||
for _, s := range []string{"", "kort", "räv", "a — b"} {
|
||||
if got := textutil.Truncate(s, 100); got != s {
|
||||
t.Errorf("Truncate(%q, 100) = %q, want it unchanged", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate_NeverCutsInsideACharacter(t *testing.T) {
|
||||
// The property, not an example: whatever the limit, the result must be
|
||||
// valid UTF-8. Byte-slicing Swedish text (å ä ö) or an em dash produces
|
||||
// an orphaned lead byte, which is what corrupted wiki/index.md (#93).
|
||||
s := strings.Repeat("räv — ål — ökäd ", 20)
|
||||
|
||||
for limit := 0; limit <= len(s)+5; limit++ {
|
||||
got := textutil.Truncate(s, limit)
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatalf("Truncate(s, %d) produced invalid UTF-8: %q", limit, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate_CountsCharactersNotBytes(t *testing.T) {
|
||||
// "räv" is 4 bytes, 3 runes. A byte-based limit of 3 would cut inside the
|
||||
// "ä"; a rune-based one returns it whole.
|
||||
if got := textutil.Truncate("räv", 3); got != "räv" {
|
||||
t.Errorf("Truncate(\"räv\", 3) = %q, want the whole word", got)
|
||||
}
|
||||
|
||||
got := textutil.Truncate("räven springer", 5)
|
||||
if utf8.RuneCountInString(got) != 6 { // 5 runes + the ellipsis
|
||||
t.Errorf("Truncate(...) = %q, want 5 runes plus the marker", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("Truncate(...) = %q, want it marked as truncated", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate_MarksOnlyWhenSomethingWasDropped(t *testing.T) {
|
||||
// An ellipsis on text that was not cut is a lie about the content.
|
||||
if got := textutil.Truncate("räv", 10); strings.HasSuffix(got, "…") {
|
||||
t.Errorf("Truncate(%q, 10) = %q, want no truncation marker", "räv", got)
|
||||
}
|
||||
if got := textutil.Truncate("räv", 3); strings.HasSuffix(got, "…") {
|
||||
t.Errorf("exact-length input must not be marked: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate_NonPositiveLimitYieldsNothingRatherThanPanicking(t *testing.T) {
|
||||
for _, limit := range []int{0, -1, -100} {
|
||||
if got := textutil.Truncate("räven", limit); got != "" {
|
||||
t.Errorf("Truncate(_, %d) = %q, want empty", limit, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user