From 0cfa03718e557680fa10c9ea365c6b1f609febda Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 Feb 2026 17:46:37 +0000 Subject: [PATCH] fix(stringutil): operate on runes instead of bytes in Truncate (#22388) Fixes https://github.com/coder/coder/issues/22375 Updates `stringutil.Truncate` to properly handle multi-byte UTF-8 characters. Adds tests for multi-byte truncation with word boundary. Created by Mux using Opus 4.6 --- coderd/taskname/taskname.go | 4 ++-- coderd/taskname/taskname_test.go | 13 ++++++++++++ coderd/util/strings/strings.go | 32 ++++++++++++++++++++--------- coderd/util/strings/strings_test.go | 32 +++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 12 deletions(-) diff --git a/coderd/taskname/taskname.go b/coderd/taskname/taskname.go index ac93202789..fe0f9d575d 100644 --- a/coderd/taskname/taskname.go +++ b/coderd/taskname/taskname.go @@ -177,7 +177,7 @@ func generateFromPrompt(prompt string) (TaskName, error) { // Ensure display name is never empty displayName = strings.ReplaceAll(name, "-", " ") } - displayName = strings.ToUpper(displayName[:1]) + displayName[1:] + displayName = strutil.Capitalize(displayName) return TaskName{ Name: taskName, @@ -269,7 +269,7 @@ func generateFromAnthropic(ctx context.Context, prompt string, apiKey string, mo // Ensure display name is never empty displayName = strings.ReplaceAll(taskNameResponse.Name, "-", " ") } - displayName = strings.ToUpper(displayName[:1]) + displayName[1:] + displayName = strutil.Capitalize(displayName) return TaskName{ Name: name, diff --git a/coderd/taskname/taskname_test.go b/coderd/taskname/taskname_test.go index 3143337092..aab53ca5f6 100644 --- a/coderd/taskname/taskname_test.go +++ b/coderd/taskname/taskname_test.go @@ -49,6 +49,19 @@ func TestGenerate(t *testing.T) { require.NotEmpty(t, taskName.DisplayName) }) + t.Run("FromPromptMultiByte", func(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "") + + ctx := testutil.Context(t, testutil.WaitShort) + + taskName := taskname.Generate(ctx, testutil.Logger(t), "über cool feature") + + require.NoError(t, codersdk.NameValid(taskName.Name)) + require.True(t, len(taskName.DisplayName) > 0) + // The display name must start with "Ü", not corrupted bytes. + require.Equal(t, "Über cool feature", taskName.DisplayName) + }) + t.Run("Fallback", func(t *testing.T) { // Ensure no API key t.Setenv("ANTHROPIC_API_KEY", "") diff --git a/coderd/util/strings/strings.go b/coderd/util/strings/strings.go index f320142da5..d2594b80a0 100644 --- a/coderd/util/strings/strings.go +++ b/coderd/util/strings/strings.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" "unicode" + "unicode/utf8" "github.com/acarl005/stripansi" "github.com/microcosm-cc/bluemonday" @@ -53,7 +54,7 @@ const ( TruncateWithFullWords TruncateOption = 1 << 1 ) -// Truncate truncates s to n characters. +// Truncate truncates s to n runes. // Additional behaviors can be specified using TruncateOptions. func Truncate(s string, n int, opts ...TruncateOption) string { var options TruncateOption @@ -63,7 +64,8 @@ func Truncate(s string, n int, opts ...TruncateOption) string { if n < 1 { return "" } - if len(s) <= n { + runes := []rune(s) + if len(runes) <= n { return s } @@ -72,18 +74,18 @@ func Truncate(s string, n int, opts ...TruncateOption) string { maxLen-- } var sb strings.Builder - // If we need to truncate to full words, find the last word boundary before n. if options&TruncateWithFullWords != 0 { - lastWordBoundary := strings.LastIndexFunc(s[:maxLen], unicode.IsSpace) + // Convert the rune-safe prefix to a string, then find + // the last word boundary (byte offset within that prefix). + truncated := string(runes[:maxLen]) + lastWordBoundary := strings.LastIndexFunc(truncated, unicode.IsSpace) if lastWordBoundary < 0 { - // We cannot find a word boundary. At this point, we'll truncate the string. - // It's better than nothing. - _, _ = sb.WriteString(s[:maxLen]) - } else { // lastWordBoundary <= maxLen - _, _ = sb.WriteString(s[:lastWordBoundary]) + _, _ = sb.WriteString(truncated) + } else { + _, _ = sb.WriteString(truncated[:lastWordBoundary]) } } else { - _, _ = sb.WriteString(s[:maxLen]) + _, _ = sb.WriteString(string(runes[:maxLen])) } if options&TruncateWithEllipsis != 0 { @@ -126,3 +128,13 @@ func UISanitize(in string) string { } return strings.TrimSpace(b.String()) } + +// Capitalize returns s with its first rune upper-cased. It is safe for +// multi-byte UTF-8 characters, unlike naive byte-slicing approaches. +func Capitalize(s string) string { + r, size := utf8.DecodeRuneInString(s) + if size == 0 { + return s + } + return string(unicode.ToUpper(r)) + s[size:] +} diff --git a/coderd/util/strings/strings_test.go b/coderd/util/strings/strings_test.go index 000fa9efa1..494246c6cf 100644 --- a/coderd/util/strings/strings_test.go +++ b/coderd/util/strings/strings_test.go @@ -57,6 +57,17 @@ func TestTruncate(t *testing.T) { {"foo bar", 1, "…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}}, {"foo bar", 0, "", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}}, {"This is a very long task prompt that should be truncated to 160 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", 160, "This is a very long task prompt that should be truncated to 160 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}}, + // Multi-byte rune handling. + {"日本語テスト", 3, "日本語", nil}, + {"日本語テスト", 4, "日本語テ", nil}, + {"日本語テスト", 6, "日本語テスト", nil}, + {"日本語テスト", 4, "日本語…", []strings.TruncateOption{strings.TruncateWithEllipsis}}, + {"🎉🎊🎈🎁", 2, "🎉🎊", nil}, + {"🎉🎊🎈🎁", 3, "🎉🎊…", []strings.TruncateOption{strings.TruncateWithEllipsis}}, + // Multi-byte with full-word truncation. + {"hello 日本語", 7, "hello…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}}, + {"hello 日本語", 8, "hello 日…", []strings.TruncateOption{strings.TruncateWithEllipsis}}, + {"日本語 テスト", 4, "日本語", []strings.TruncateOption{strings.TruncateWithFullWords}}, } { tName := fmt.Sprintf("%s_%d", tt.s, tt.n) for _, opt := range tt.options { @@ -107,3 +118,24 @@ func TestUISanitize(t *testing.T) { }) } } + +func TestCapitalize(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + expected string + }{ + {"", ""}, + {"hello", "Hello"}, + {"über", "Über"}, + {"Hello", "Hello"}, + {"a", "A"}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%q", tt.input), func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, strings.Capitalize(tt.input)) + }) + } +}