@@ -114,6 +114,15 @@ fn validate_session_id(session_id: &str) -> Result<()> {
114114 )
115115}
116116
117+ /// Safely get a string prefix by character count, not byte count.
118+ /// This avoids panics on multi-byte UTF-8 characters.
119+ fn safe_char_prefix ( s : & str , max_chars : usize ) -> & str {
120+ match s. char_indices ( ) . nth ( max_chars) {
121+ Some ( ( byte_idx, _) ) => & s[ ..byte_idx] ,
122+ None => s, // String has fewer than max_chars characters
123+ }
124+ }
125+
117126/// Get the lock file path.
118127fn get_lock_file_path ( ) -> PathBuf {
119128 dirs:: home_dir ( )
@@ -156,7 +165,7 @@ pub fn is_session_locked(session_id: &str) -> bool {
156165 match load_lock_file ( ) {
157166 Ok ( lock_file) => lock_file. locked_sessions . iter ( ) . any ( |entry| {
158167 entry. session_id == session_id
159- || session_id. starts_with ( & entry. session_id [ .. 8 . min ( entry . session_id . len ( ) ) ] )
168+ || session_id. starts_with ( safe_char_prefix ( & entry. session_id , 8 ) )
160169 } ) ,
161170 Err ( _) => false ,
162171 }
@@ -308,7 +317,7 @@ async fn run_list(args: LockListArgs) -> Result<()> {
308317 println ! ( "{}" , "-" . repeat( 60 ) ) ;
309318
310319 for entry in & lock_file. locked_sessions {
311- let short_id = & entry. session_id [ .. 8 . min ( entry . session_id . len ( ) ) ] ;
320+ let short_id = safe_char_prefix ( & entry. session_id , 8 ) ;
312321 println ! ( " {} - locked at {}" , short_id, entry. locked_at) ;
313322 if let Some ( ref reason) = entry. reason {
314323 println ! ( " Reason: {}" , reason) ;
@@ -332,7 +341,7 @@ async fn run_check(args: LockCheckArgs) -> Result<()> {
332341 e. session_id == args. session_id
333342 || args
334343 . session_id
335- . starts_with ( & e. session_id [ .. 8 . min ( e . session_id . len ( ) ) ] )
344+ . starts_with ( safe_char_prefix ( & e. session_id , 8 ) )
336345 } ) ;
337346
338347 if is_locked {
@@ -342,7 +351,7 @@ async fn run_check(args: LockCheckArgs) -> Result<()> {
342351 e. session_id == args. session_id
343352 || args
344353 . session_id
345- . starts_with ( & e. session_id [ .. 8 . min ( e . session_id . len ( ) ) ] )
354+ . starts_with ( safe_char_prefix ( & e. session_id , 8 ) )
346355 } ) && let Some ( ref reason) = entry. reason
347356 {
348357 println ! ( "Reason: {}" , reason) ;
@@ -508,4 +517,39 @@ mod tests {
508517 let path_str = path. to_string_lossy ( ) ;
509518 assert ! ( path_str. contains( ".cortex" ) ) ;
510519 }
520+
521+ #[ test]
522+ fn test_safe_char_prefix_ascii ( ) {
523+ // ASCII strings should work correctly
524+ assert_eq ! ( safe_char_prefix( "abcdefghij" , 8 ) , "abcdefgh" ) ;
525+ assert_eq ! ( safe_char_prefix( "abc" , 8 ) , "abc" ) ;
526+ assert_eq ! ( safe_char_prefix( "" , 8 ) , "" ) ;
527+ assert_eq ! ( safe_char_prefix( "12345678" , 8 ) , "12345678" ) ;
528+ }
529+
530+ #[ test]
531+ fn test_safe_char_prefix_utf8_multibyte ( ) {
532+ // Multi-byte UTF-8 characters should not panic
533+ // Each emoji is 4 bytes, so 8 chars = 32 bytes
534+ let emoji_id = "🔥🎉🚀💡🌟✨🎯🔮extra" ;
535+ assert_eq ! ( safe_char_prefix( emoji_id, 8 ) , "🔥🎉🚀💡🌟✨🎯🔮" ) ;
536+
537+ // Mixed ASCII and multi-byte
538+ let mixed = "ab🔥cd🎉ef" ;
539+ assert_eq ! ( safe_char_prefix( mixed, 4 ) , "ab🔥c" ) ;
540+ assert_eq ! ( safe_char_prefix( mixed, 8 ) , "ab🔥cd🎉ef" ) ;
541+
542+ // Chinese characters (3 bytes each)
543+ let chinese = "中文测试会话标识符" ;
544+ assert_eq ! ( safe_char_prefix( chinese, 4 ) , "中文测试" ) ;
545+ }
546+
547+ #[ test]
548+ fn test_safe_char_prefix_boundary ( ) {
549+ // Edge cases
550+ assert_eq ! ( safe_char_prefix( "a" , 0 ) , "" ) ;
551+ assert_eq ! ( safe_char_prefix( "a" , 1 ) , "a" ) ;
552+ assert_eq ! ( safe_char_prefix( "🔥" , 1 ) , "🔥" ) ;
553+ assert_eq ! ( safe_char_prefix( "🔥" , 0 ) , "" ) ;
554+ }
511555}
0 commit comments