@@ -292,6 +292,52 @@ pub enum WorkspaceRootValidation {
292292 Invalid { reason : String } ,
293293}
294294
295+ /// True when `root` is the same folder as `base`, or nested inside it —
296+ /// matching on path-segment boundaries so `/work` contains `/work/proj` but
297+ /// NOT `/workspace`. Both arguments must already be normalized via
298+ /// [`normalize_workspace_root`] (same casing + separator conventions) for the
299+ /// comparison to be meaningful.
300+ ///
301+ /// Used to scope a workspace root to a Space by its configured base
302+ /// directories: a reported root at or under a base dir belongs to that Space.
303+ pub fn path_is_within ( root : & str , base : & str ) -> bool {
304+ if base. is_empty ( ) || root. is_empty ( ) {
305+ return false ;
306+ }
307+ if root == base {
308+ return true ;
309+ }
310+ // Separator comes from the (normalized) base: Windows drive/UNC paths use
311+ // `\`, POSIX uses `/`.
312+ let sep = if base. contains ( '\\' ) { '\\' } else { '/' } ;
313+ if base. ends_with ( sep) {
314+ // `base` is already a filesystem/drive root (`c:\`, `\\`, `/`) — its
315+ // trailing separator IS the boundary, so a plain prefix check is right.
316+ root. starts_with ( base)
317+ } else {
318+ // Otherwise require the next char to be the separator so we only match
319+ // whole segments (`/work` ⊄ `/workspace`).
320+ let mut prefix = String :: with_capacity ( base. len ( ) + 1 ) ;
321+ prefix. push_str ( base) ;
322+ prefix. push ( sep) ;
323+ root. starts_with ( & prefix)
324+ }
325+ }
326+
327+ /// Of all `bases`, return the **longest** one that contains `root` (most
328+ /// specific wins when base dirs nest — `/work/client` beats `/work`), or
329+ /// `None` when none contain it. Inputs must be normalized. Equal-length bases
330+ /// can't both contain the same root, so length ties never occur in practice.
331+ pub fn longest_matching_base < ' a > (
332+ root : & str ,
333+ bases : impl IntoIterator < Item = & ' a str > ,
334+ ) -> Option < & ' a str > {
335+ bases
336+ . into_iter ( )
337+ . filter ( |b| path_is_within ( root, b) )
338+ . max_by_key ( |b| b. len ( ) )
339+ }
340+
295341/// Validate a user-entered workspace root.
296342///
297343/// Applied on manual add/edit ONLY — roots reported by connected MCP
@@ -388,6 +434,55 @@ fn check_windows_reserved_chars(path: &str) -> Result<(), String> {
388434mod tests {
389435 use super :: * ;
390436
437+ // ---- path_is_within / longest_matching_base --------------------------
438+
439+ #[ test]
440+ fn within_posix_segment_boundaries ( ) {
441+ assert ! ( path_is_within( "/work" , "/work" ) ) ; // same folder
442+ assert ! ( path_is_within( "/work/proj" , "/work" ) ) ; // nested
443+ assert ! ( path_is_within( "/work/proj/deep" , "/work" ) ) ; // deeper
444+ assert ! ( !path_is_within( "/workspace" , "/work" ) ) ; // NOT a segment boundary
445+ assert ! ( !path_is_within( "/other" , "/work" ) ) ;
446+ assert ! ( !path_is_within( "/work" , "/work/proj" ) ) ; // parent is not within child
447+ }
448+
449+ #[ test]
450+ fn within_windows_is_case_and_sep_consistent ( ) {
451+ // Inputs are already-normalized Windows form (lower-case, `\`).
452+ assert ! ( path_is_within( "c:\\ work\\ proj" , "c:\\ work" ) ) ;
453+ assert ! ( path_is_within( "c:\\ work" , "c:\\ work" ) ) ;
454+ assert ! ( !path_is_within( "c:\\ workspace" , "c:\\ work" ) ) ;
455+ // Drive root keeps its trailing separator and contains everything on it.
456+ assert ! ( path_is_within( "c:\\ proj" , "c:\\ " ) ) ;
457+ }
458+
459+ #[ test]
460+ fn within_filesystem_roots ( ) {
461+ assert ! ( path_is_within( "/work" , "/" ) ) ; // POSIX root contains all
462+ assert ! ( path_is_within( "/" , "/" ) ) ;
463+ assert ! ( !path_is_within( "/x" , "" ) ) ; // empty base never matches
464+ assert ! ( !path_is_within( "" , "/x" ) ) ; // empty root never matches
465+ }
466+
467+ #[ test]
468+ fn longest_base_wins_when_nested ( ) {
469+ let bases = [ "/work" , "/work/client" , "/other" ] ;
470+ assert_eq ! (
471+ longest_matching_base( "/work/client/app" , bases. iter( ) . copied( ) ) ,
472+ Some ( "/work/client" ) ,
473+ ) ;
474+ // A root under only the broad base falls to that one.
475+ assert_eq ! (
476+ longest_matching_base( "/work/solo" , bases. iter( ) . copied( ) ) ,
477+ Some ( "/work" ) ,
478+ ) ;
479+ // No base contains it.
480+ assert_eq ! (
481+ longest_matching_base( "/elsewhere" , bases. iter( ) . copied( ) ) ,
482+ None ,
483+ ) ;
484+ }
485+
391486 // ---- normalize -------------------------------------------------------
392487
393488 #[ test]
0 commit comments