@@ -58,16 +58,32 @@ pub fn path_ends_with_suffix(path: &str, suffix: &str) -> bool {
5858 }
5959
6060 let start = path. len ( ) - suffix. len ( ) ;
61- let Some ( path_suffix) = path. get ( start..) else {
61+
62+ // Must land on a char boundary — multi-byte UTF-8 can make
63+ // `path.len() - suffix.len()` land inside a char.
64+ if !path. is_char_boundary ( start) {
6265 return false ;
63- } ;
66+ }
6467
65- if !path_suffix . eq_ignore_ascii_case ( suffix) {
68+ if !path [ start.. ] . eq_ignore_ascii_case ( suffix) {
6669 return false ;
6770 }
6871
69- // Exact match, or the character before is /
70- start == 0 || path_bytes[ start - 1 ] == b'/'
72+ // Exact match, or the character before is '/'.
73+ // `start` is a char boundary but `start - 1` may be inside a multi-byte
74+ // char, so scan backward to find the preceding ASCII byte.
75+ if start == 0 {
76+ return true ;
77+ }
78+ let mut i = start;
79+ while i > 0 {
80+ i -= 1 ;
81+ // ASCII bytes (0..128) are single-byte UTF-8 code units
82+ if path_bytes[ i] < 128 {
83+ return path_bytes[ i] == b'/' ;
84+ }
85+ }
86+ false
7187}
7288
7389#[ inline]
@@ -78,6 +94,11 @@ pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
7894 return false ;
7995 }
8096 let start = name_bytes. len ( ) - ext_bytes. len ( ) - 1 ;
97+ // `.` is ASCII (single byte), so `start` must be a char boundary.
98+ // If it lands inside a multi-byte char the extension can't match.
99+ if start > 0 && !file_name. is_char_boundary ( start) {
100+ return false ;
101+ }
81102 name_bytes. get ( start) == Some ( & b'.' ) && name_bytes[ start + 1 ..] . eq_ignore_ascii_case ( ext_bytes)
82103}
83104
@@ -91,6 +112,7 @@ pub fn path_contains_segment(path: &str, segment: &str) -> bool {
91112 // Check segment/ at start of path
92113 if path_bytes. len ( ) > segment_len
93114 && path_bytes. get ( segment_len) == Some ( & b'/' )
115+ && path. is_char_boundary ( segment_len)
94116 && path_bytes[ ..segment_len] . eq_ignore_ascii_case ( segment_bytes)
95117 {
96118 return true ;
@@ -107,6 +129,8 @@ pub fn path_contains_segment(path: &str, segment: &str) -> bool {
107129 let end = start + segment_len;
108130 if end < path_bytes. len ( )
109131 && path_bytes[ end] == b'/'
132+ && path. is_char_boundary ( start)
133+ && path. is_char_boundary ( end)
110134 && path_bytes[ start..end] . eq_ignore_ascii_case ( segment_bytes)
111135 {
112136 return true ;
@@ -416,18 +440,21 @@ fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
416440mod tests {
417441 use super :: * ;
418442
443+ #[ derive( Clone ) ]
419444 struct TestItem {
420445 relative_path : & ' static str ,
421446 file_name : & ' static str ,
422447 }
423448
424449 impl Constrainable for TestItem {
425- fn relative_path ( & self ) -> & str {
426- self . relative_path
450+ fn write_file_name ( & self , _arena : ArenaPtr , out : & mut String ) {
451+ out. clear ( ) ;
452+ out. push_str ( self . file_name ) ;
427453 }
428454
429- fn file_name ( & self ) -> & str {
430- self . file_name
455+ fn write_relative_path ( & self , _arena : ArenaPtr , out : & mut String ) {
456+ out. clear ( ) ;
457+ out. push_str ( self . relative_path ) ;
431458 }
432459
433460 fn git_status ( & self ) -> Option < git2:: Status > {
@@ -550,30 +577,60 @@ mod tests {
550577 }
551578
552579 #[ test]
553- <<<<<<< HEAD
554580 fn test_path_ends_with_suffix_does_not_panic_on_unicode_suffix ( ) {
555581 assert ! ( !path_ends_with_suffix( "유니코드_파일_테스트.csv" , "트.c" ) ) ;
556582 assert ! ( path_ends_with_suffix(
557583 "data/유니코드_파일_테스트.csv" ,
558584 "유니코드_파일_테스트.csv"
559- =======
585+ ) ) ;
586+ }
587+
588+ #[ test]
560589 fn test_path_ends_with_suffix_unicode_apostrophe_mismatch ( ) {
561590 assert ! ( !path_ends_with_suffix(
562591 "dir/\u{2019} bar/file.txt" ,
563592 "'bar/file.txt"
564- >>>>>>> origin/main
565593 ) ) ;
566594 }
567595
568596 #[ test]
569- <<<<<<< HEAD
597+ fn test_path_ends_with_suffix_unicode_space_mismatch ( ) {
598+ assert ! ( !path_ends_with_suffix(
599+ "dir/\u{202f} am/file.txt" ,
600+ " am/file.txt"
601+ ) ) ;
602+ }
603+
604+ #[ test]
570605 fn test_path_contains_segment_does_not_panic_on_unicode_segment ( ) {
571606 assert ! ( !path_contains_segment( "문서/notes.txt" , "문x" ) ) ;
572607 assert ! ( path_contains_segment( "프로젝트/문서/notes.txt" , "문서" ) ) ;
573608 }
574609
610+ #[ test]
611+ fn test_path_contains_segment_unicode_no_panic ( ) {
612+ assert ! ( !path_contains_segment(
613+ "Library/Cloud/Project\u{2019} s Folder/books.ttl" ,
614+ "Project's Folder"
615+ ) ) ;
616+ }
617+
618+ #[ test]
619+ fn test_file_has_extension_unicode_no_panic ( ) {
620+ assert ! ( !file_has_extension( "cat\u{00e9} .rs" , "s" ) ) ;
621+ }
622+
623+ #[ test]
624+ fn test_file_has_extension_unicode_filename ( ) {
625+ assert ! ( file_has_extension( "운영-가이드.md" , "md" ) ) ;
626+ assert ! ( file_has_extension( "테스트.csv" , "csv" ) ) ;
627+ assert ! ( !file_has_extension( "테스트.csv" , "md" ) ) ;
628+ }
629+
575630 #[ test]
576631 fn test_apply_constraints_file_path_with_unicode_suffix ( ) {
632+ let arena_ptr = ArenaPtr ( std:: ptr:: null ( ) ) ;
633+
577634 let item = TestItem {
578635 relative_path : "data/유니코드_파일_테스트.csv" ,
579636 file_name : "유니코드_파일_테스트.csv" ,
@@ -582,37 +639,41 @@ mod tests {
582639 let exact = [ Constraint :: FilePath ( "유니코드_파일_테스트.csv" ) ] ;
583640 let mismatch = [ Constraint :: FilePath ( "트.c" ) ] ;
584641
585- let exact_items = [ item] ;
586- let exact_matches = apply_constraints( & exact_items, & exact) . expect( "constraints applied" ) ;
642+ let exact_items = [ item. clone ( ) ] ;
643+ let exact_matches =
644+ apply_constraints ( & exact_items, & exact, arena_ptr) . expect ( "constraints applied" ) ;
587645 assert_eq ! ( exact_matches. len( ) , 1 ) ;
588646
589- let mismatch_item = TestItem {
590- relative_path: "data/유니코드_파일_테스트.csv" ,
591- file_name: "유니코드_파일_테스트.csv" ,
592- } ;
593- let mismatch_items = [ mismatch_item] ;
647+ let mismatch_items = [ item] ;
594648 let mismatch_matches =
595- apply_constraints( & mismatch_items, & mismatch) . expect( "constraints applied" ) ;
649+ apply_constraints ( & mismatch_items, & mismatch, arena_ptr ) . expect ( "constraints applied" ) ;
596650 assert ! ( mismatch_matches. is_empty( ) ) ;
597- =======
598- fn test_path_ends_with_suffix_unicode_space_mismatch( ) {
599- assert!( !path_ends_with_suffix(
600- "dir/\u{202f} am/file.txt" ,
601- " am/file.txt"
602- ) ) ;
603651 }
604652
605653 #[ test]
606- fn test_path_contains_segment_unicode_no_panic( ) {
607- assert!( !path_contains_segment(
608- "Library/Cloud/Project\u{2019} s Folder/books.ttl" ,
609- "Project's Folder"
654+ fn test_unicode_path_no_panic_real_korean_cases ( ) {
655+ // Real Korean paths that caused panics
656+ let path1 = "Downloads/(커리큘럼) hermes agent_정승현님 - 1차 커리큘럼 (강사님 작성).csv" ;
657+ let path2 = "hermes-agent-lecture-materials/세부_커리큘럼_최종.csv" ;
658+ let path3 = "projects/fastcampus-hermes-agent-curriculum/chapters/part-02-Hermes-설치-및-기본-사용/section-02-doctor로-설치-상태-검증/research/03-fix가-자동-수정하는-것과-못하는-것.md" ;
659+
660+ // These must not panic regardless of segment/suffix used
661+ assert ! ( !path_contains_segment( path1, "작성" ) ) ;
662+ assert ! ( !path_ends_with_suffix( path1, "작성.csv" ) ) ;
663+ assert ! ( !path_contains_segment( path2, "최종" ) ) ;
664+ assert ! ( !path_ends_with_suffix( path2, "최종.csv" ) ) ;
665+ assert ! ( !path_contains_segment( path3, "수정" ) ) ;
666+ assert ! ( !path_ends_with_suffix( path3, "것.md" ) ) ;
667+
668+ // Positive cases should still work
669+ assert ! ( path_contains_segment(
670+ path2,
671+ "hermes-agent-lecture-materials"
610672 ) ) ;
611- }
612-
613- #[ test]
614- fn test_file_has_extension_unicode_no_panic( ) {
615- assert!( !file_has_extension( "cat\u{00e9} .rs" , "s" ) ) ;
616- >>>>>>> origin/main
673+ assert ! ( path_ends_with_suffix(
674+ path1,
675+ "(커리큘럼) hermes agent_정승현님 - 1차 커리큘럼 (강사님 작성).csv"
676+ ) ) ;
677+ assert ! ( path_ends_with_suffix( path2, "세부_커리큘럼_최종.csv" ) ) ;
617678 }
618679}
0 commit comments