A truncated error message cut a name in half. The code kept the first 40 bytes with String.sub msg 0 40, the name near the end was Korean, and the cut landed inside a three-byte UTF-8 sequence. The terminal showed a replacement character where the last syllable should have been.
String.length in OCaml is a byte count. The String module describes a string as an immutable sequence of bytes, and String.sub takes byte positions. Go's len(s) is the same, and s[:40] slices bytes. Neither is wrong. They just don't count what the person reading the message counts.
The local fix is to back the cut up to the start of a character. In Go that's a short loop on utf8.RuneStart.
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
i := max
for i > 0 && !utf8.RuneStart(s[i]) {
i--
}
return s[:i]
}
s[i] here is the first byte that would be dropped. If it starts a character, the cut is clean at i. If it's a continuation byte, the loop walks left until it finds the start of the character being split and cuts before it. On a message that ends in 김다현 with the limit inside 다, that returns the text up to 김 as valid UTF-8, where s[:40] left two stray bytes.
In OCaml, String.get_utf_8_uchar decodes at an index and Uchar.utf_decode_length says how far to step, so the same walk is a few lines longer and reads about the same. The Go version passes a few more strings too, a pure ASCII one and one where the limit already falls on a character boundary, and both come back cut at the limit itself.
That keeps the output valid UTF-8, which is all I'm claiming for it. Whether a character a person sees stays whole is another matter.