@@ -121,11 +121,17 @@ fn write_tee_file(
121121 let filename = format ! ( "{}_{}.log" , epoch, slug) ;
122122 let filepath = tee_dir. join ( filename) ;
123123
124- // Truncate at max_file_size
124+ // Truncate at max_file_size (find a safe UTF-8 char boundary)
125125 let content = if raw. len ( ) > max_file_size {
126+ let boundary = raw
127+ . char_indices ( )
128+ . take_while ( |( i, _) | * i < max_file_size)
129+ . last ( )
130+ . map ( |( i, c) | i + c. len_utf8 ( ) )
131+ . unwrap_or ( 0 ) ;
126132 format ! (
127133 "{}\n \n --- truncated at {} bytes ---" ,
128- & raw[ ..max_file_size ] ,
134+ & raw[ ..boundary ] ,
129135 max_file_size
130136 )
131137 } else {
@@ -356,6 +362,47 @@ mod tests {
356362 assert ! ( content. len( ) < 2000 ) ;
357363 }
358364
365+ #[ test]
366+ fn test_write_tee_file_truncation_utf8_boundary ( ) {
367+ let tmpdir = tempfile:: tempdir ( ) . unwrap ( ) ;
368+ // Create a string where the truncation point falls inside a multi-byte char.
369+ // Japanese chars are 3 bytes each in UTF-8.
370+ // 332 chars * 3 bytes = 996 bytes, then one more = 999 bytes.
371+ // With max_file_size=998, the cut falls mid-character.
372+ let japanese = "\u{6F22} " . repeat ( 333 ) ; // 999 bytes of 3-byte chars
373+ assert_eq ! ( japanese. len( ) , 999 ) ;
374+
375+ // Truncate at 998 — falls in the middle of the 333rd character
376+ let result = write_tee_file ( & japanese, "test_utf8" , tmpdir. path ( ) , 998 , 20 ) ;
377+ assert ! ( result. is_some( ) ) ;
378+
379+ let path = result. unwrap ( ) ;
380+ let content = fs:: read_to_string ( & path) . unwrap ( ) ;
381+ assert ! ( content. contains( "--- truncated at 998 bytes ---" ) ) ;
382+ // Should contain 332 full characters (996 bytes), not panic
383+ assert ! ( content. starts_with( & "\u{6F22} " . repeat( 332 ) ) ) ;
384+ }
385+
386+ #[ test]
387+ fn test_write_tee_file_truncation_emoji ( ) {
388+ let tmpdir = tempfile:: tempdir ( ) . unwrap ( ) ;
389+ // Emoji are 4 bytes each in UTF-8
390+ let emojis = "\u{1F600} " . repeat ( 100 ) ; // 400 bytes
391+ assert_eq ! ( emojis. len( ) , 400 ) ;
392+
393+ // Truncate at 201 — falls mid-emoji (4-byte boundary is at 200, 204)
394+ let result = write_tee_file ( & emojis, "test_emoji" , tmpdir. path ( ) , 201 , 20 ) ;
395+ assert ! ( result. is_some( ) ) ;
396+
397+ let path = result. unwrap ( ) ;
398+ let content = fs:: read_to_string ( & path) . unwrap ( ) ;
399+ assert ! ( content. contains( "--- truncated at 201 bytes ---" ) ) ;
400+ // The emoji portion should be exactly 200 bytes (50 emojis),
401+ // rounded down from 201 to the nearest char boundary
402+ let target = "\u{1F600} " . repeat ( 50 ) ;
403+ assert ! ( content. starts_with( & target) ) ;
404+ }
405+
359406 #[ test]
360407 fn test_cleanup_old_files ( ) {
361408 let tmpdir = tempfile:: tempdir ( ) . unwrap ( ) ;
0 commit comments