@@ -10,7 +10,10 @@ use std::any::Any;
1010use std:: cmp:: Ordering ;
1111
1212use super :: errors:: { Error , Result } ;
13- use super :: types:: { FileInfo , GrepResult , TreeEntry , WriteFlag } ;
13+ use super :: glob:: {
14+ compare_rel_paths, decode_offset_token, encode_offset_token, PreparedGlob ,
15+ } ;
16+ use super :: types:: { FileInfo , GlobEntry , GlobPage , GrepResult , TreeEntry , WriteFlag } ;
1417
1518/// Normalize a path for prefix comparisons.
1619///
@@ -470,6 +473,65 @@ pub trait FileSystem: Send + Sync + Any {
470473 Ok ( result)
471474 }
472475
476+ /// Return one page of flat glob results under `path`.
477+ ///
478+ /// The default implementation preserves the current Python behavior by
479+ /// reusing `tree_directory()` and matching against the returned `rel_path`
480+ /// values, then slicing matches with an opaque continuation token.
481+ async fn glob_directory (
482+ & self ,
483+ path : & str ,
484+ pattern : & str ,
485+ show_hidden : bool ,
486+ page_size : Option < usize > ,
487+ level_limit : Option < usize > ,
488+ continuation_token : Option < String > ,
489+ ) -> Result < GlobPage > {
490+ let matcher = PreparedGlob :: new ( pattern) ?;
491+ if matches ! ( page_size, Some ( 0 ) ) {
492+ return Err ( Error :: invalid_operation ( "page_size must be positive" ) ) ;
493+ }
494+
495+ let entries = self
496+ . tree_directory ( path, show_hidden, None , level_limit)
497+ . await ?;
498+
499+ let mut matched = Vec :: new ( ) ;
500+ for entry in entries {
501+ if matcher. is_match ( & entry. rel_path ) {
502+ matched. push ( GlobEntry {
503+ path : entry. path ,
504+ rel_path : entry. rel_path ,
505+ name : entry. info . name ,
506+ is_dir : entry. info . is_dir ,
507+ } ) ;
508+ }
509+ }
510+ matched. sort_by ( |left, right| compare_rel_paths ( & left. rel_path , & right. rel_path ) ) ;
511+
512+ let start = decode_offset_token (
513+ continuation_token. as_deref ( ) ,
514+ path,
515+ pattern,
516+ show_hidden,
517+ level_limit,
518+ ) ?;
519+ if start > matched. len ( ) {
520+ return Err ( Error :: invalid_operation ( "continuation token out of range" ) ) ;
521+ }
522+ let end = page_size
523+ . map ( |limit| start. saturating_add ( limit) )
524+ . unwrap_or ( matched. len ( ) )
525+ . min ( matched. len ( ) ) ;
526+ let next_token = ( end < matched. len ( ) )
527+ . then ( || encode_offset_token ( end, path, pattern, show_hidden, level_limit) ) ;
528+
529+ Ok ( GlobPage {
530+ entries : matched[ start..end] . to_vec ( ) ,
531+ next_token,
532+ } )
533+ }
534+
473535 /// Internal recursive helper for tree_directory.
474536 ///
475537 /// # Arguments
@@ -1063,4 +1125,139 @@ mod tests {
10631125 assert ! ( names. contains( & "secret.txt" . to_string( ) ) ) ;
10641126 assert ! ( !names. contains( & ".hidden_file" . to_string( ) ) ) ;
10651127 }
1128+
1129+ /// Test helper that calls `glob_directory` with a fixed `/root` query root.
1130+ ///
1131+ /// Args:
1132+ /// - `fs`: The `TreeFS` instance under test.
1133+ /// - `pattern`: The glob pattern to match.
1134+ /// - `page_size`: The requested page size.
1135+ /// - `continuation_token`: The pagination token for the next page.
1136+ ///
1137+ /// Returns:
1138+ /// - A `GlobPage` on success. In tests this helper uses `unwrap()`, so any
1139+ /// error fails the test immediately.
1140+ async fn root_glob (
1141+ fs : & TreeFS ,
1142+ pattern : & str ,
1143+ page_size : Option < usize > ,
1144+ continuation_token : Option < String > ,
1145+ ) -> crate :: core:: GlobPage {
1146+ fs. glob_directory ( "/root" , pattern, false , page_size, None , continuation_token)
1147+ . await
1148+ . unwrap ( )
1149+ }
1150+
1151+ /// Test helper that extracts each entry's `rel_path` from a `GlobPage`.
1152+ ///
1153+ /// Args:
1154+ /// - `page`: The glob page whose relative paths should be collected.
1155+ ///
1156+ /// Returns:
1157+ /// - A list of `rel_path` values in their original order, suitable for
1158+ /// result-content and ordering assertions.
1159+ fn glob_rel_paths ( page : & crate :: core:: GlobPage ) -> Vec < String > {
1160+ page. entries
1161+ . iter ( )
1162+ . map ( |entry| entry. rel_path . clone ( ) )
1163+ . collect ( )
1164+ }
1165+
1166+ #[ tokio:: test]
1167+ async fn test_glob_directory_matches_full_relative_path_semantics ( ) {
1168+ let fs = TreeFS :: default ( )
1169+ . with_dir_entries ( "/root" , vec ! [ ( "sub" , true ) , ( "top.md" , false ) ] )
1170+ . with_dir_entries (
1171+ "/root/sub" ,
1172+ vec ! [ ( "nested.md" , false ) , ( "nested.txt" , false ) ] ,
1173+ ) ;
1174+
1175+ let page = root_glob ( & fs, "**/*.md" , None , None ) . await ;
1176+
1177+ assert_eq ! ( glob_rel_paths( & page) , vec![ "sub/nested.md" , "top.md" ] ) ;
1178+ assert ! ( page. next_token. is_none( ) ) ;
1179+ }
1180+
1181+ #[ tokio:: test]
1182+ async fn test_glob_directory_anchors_multi_segment_patterns_at_root ( ) {
1183+ let fs = TreeFS :: default ( )
1184+ . with_dir_entries ( "/root" , vec ! [ ( "a" , true ) , ( "x" , true ) ] )
1185+ . with_dir_entries ( "/root/a" , vec ! [ ( "b" , true ) ] )
1186+ . with_dir_entries ( "/root/a/b" , vec ! [ ( "c.md" , false ) ] )
1187+ . with_dir_entries ( "/root/x" , vec ! [ ( "a" , true ) ] )
1188+ . with_dir_entries ( "/root/x/a" , vec ! [ ( "b" , true ) ] )
1189+ . with_dir_entries ( "/root/x/a/b" , vec ! [ ( "c.md" , false ) ] ) ;
1190+
1191+ let page = root_glob ( & fs, "a/**/*.md" , None , None ) . await ;
1192+
1193+ assert_eq ! ( glob_rel_paths( & page) , vec![ "a/b/c.md" ] ) ;
1194+ }
1195+
1196+ #[ tokio:: test]
1197+ async fn test_glob_directory_paginates_with_opaque_offset_tokens ( ) {
1198+ let fs = TreeFS :: default ( ) . with_dir_entries (
1199+ "/root" ,
1200+ vec ! [ ( "a.md" , false ) , ( "b.md" , false ) , ( "c.md" , false ) ] ,
1201+ ) ;
1202+
1203+ let first = root_glob ( & fs, "*.md" , Some ( 2 ) , None ) . await ;
1204+ assert_eq ! ( glob_rel_paths( & first) , vec![ "a.md" , "b.md" ] ) ;
1205+ assert ! ( first. next_token. is_some( ) ) ;
1206+
1207+ let second = root_glob ( & fs, "*.md" , Some ( 2 ) , first. next_token ) . await ;
1208+ assert_eq ! ( glob_rel_paths( & second) , vec![ "c.md" ] ) ;
1209+ assert ! ( second. next_token. is_none( ) ) ;
1210+ }
1211+
1212+ #[ tokio:: test]
1213+ async fn test_glob_directory_rejects_token_from_different_query_scope ( ) {
1214+ let fs = TreeFS :: default ( ) . with_dir_entries (
1215+ "/root" ,
1216+ vec ! [ ( "a.md" , false ) , ( "b.md" , false ) , ( "c.md" , false ) ] ,
1217+ ) ;
1218+
1219+ let first = root_glob ( & fs, "*.md" , Some ( 2 ) , None ) . await ;
1220+ let err = fs
1221+ . glob_directory ( "/root" , "*.txt" , false , Some ( 2 ) , None , first. next_token )
1222+ . await
1223+ . unwrap_err ( ) ;
1224+
1225+ assert ! ( matches!( err, Error :: InvalidOperation ( _) ) ) ;
1226+ }
1227+
1228+ #[ tokio:: test]
1229+ async fn test_glob_directory_empty_pattern_is_invalid ( ) {
1230+ let fs = TreeFS :: default ( ) . with_dir_entries ( "/root" , vec ! [ ( "a.md" , false ) ] ) ;
1231+
1232+ let err = fs
1233+ . glob_directory ( "/root" , "" , false , None , None , None )
1234+ . await
1235+ . unwrap_err ( ) ;
1236+
1237+ assert ! ( matches!( err, Error :: InvalidOperation ( _) ) ) ;
1238+ }
1239+
1240+ #[ tokio:: test]
1241+ async fn test_glob_directory_empty_pattern_is_invalid_for_empty_directory ( ) {
1242+ let fs = TreeFS :: default ( ) . with_dir_entries ( "/root" , vec ! [ ] ) ;
1243+
1244+ let err = fs
1245+ . glob_directory ( "/root" , "" , false , None , None , None )
1246+ . await
1247+ . unwrap_err ( ) ;
1248+
1249+ assert ! ( matches!( err, Error :: InvalidOperation ( _) ) ) ;
1250+ }
1251+
1252+ #[ tokio:: test]
1253+ async fn test_glob_directory_zero_page_size_is_invalid ( ) {
1254+ let fs = TreeFS :: default ( ) . with_dir_entries ( "/root" , vec ! [ ( "a.md" , false ) ] ) ;
1255+
1256+ let err = fs
1257+ . glob_directory ( "/root" , "*.md" , false , Some ( 0 ) , None , None )
1258+ . await
1259+ . unwrap_err ( ) ;
1260+
1261+ assert ! ( matches!( err, Error :: InvalidOperation ( _) ) ) ;
1262+ }
10661263}
0 commit comments