From 549ec2a177c290087342ce2ffc072ab31b86b514 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 20 Jun 2026 00:06:30 +0100 Subject: [PATCH] fix(search): truncate match lines on a rune boundary truncateLine byte-sliced s[:maxLen-3], which can split a multibyte UTF-8 character and emit an invalid sequence (mojibake) on long non-ASCII match lines. Back off to the nearest rune boundary via utf8.RuneStart. Adds TestTruncateLineRuneSafe. --- internal/search/search.go | 10 ++++++++-- internal/search/search_test.go | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/internal/search/search.go b/internal/search/search.go index 85eaf6a..970c186 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "strings" + "unicode/utf8" json "github.com/goccy/go-json" "github.com/lukaszraczylo/mcp-filepuff/internal/config" @@ -488,10 +489,15 @@ func writeClusteredResults(sb *strings.Builder, results []Result) { } } -// truncateLine truncates a line if it exceeds maxLen. +// truncateLine truncates a line if it exceeds maxLen bytes, backing off to a +// rune boundary so a multibyte character is never split into invalid UTF-8. func truncateLine(s string, maxLen int) string { if len(s) <= maxLen { return s } - return s[:maxLen-3] + "..." + end := max(maxLen-3, 0) + for end > 0 && !utf8.RuneStart(s[end]) { + end-- + } + return s[:end] + "..." } diff --git a/internal/search/search_test.go b/internal/search/search_test.go index ee64cb5..cbb25af 100644 --- a/internal/search/search_test.go +++ b/internal/search/search_test.go @@ -8,10 +8,27 @@ import ( "path/filepath" "strings" "testing" + "unicode/utf8" "github.com/lukaszraczylo/mcp-filepuff/internal/config" ) +// truncateLine must back off to a rune boundary so multibyte characters are +// never split into invalid UTF-8. +func TestTruncateLineRuneSafe(t *testing.T) { + s := strings.Repeat("é", 200) // 2 bytes each → 400 bytes + out := truncateLine(s, 50) + if !utf8.ValidString(out) { + t.Errorf("truncated output is not valid UTF-8: %q", out) + } + if !strings.HasSuffix(out, "...") { + t.Errorf("expected ellipsis suffix, got %q", out) + } + if got := truncateLine("hi", 50); got != "hi" { + t.Errorf("short string should be unchanged, got %q", got) + } +} + func TestNew(t *testing.T) { cfg := config.Default() logger := slog.New(slog.NewTextHandler(os.Stderr, nil))