@@ -44,6 +44,11 @@ impl LocalTurboState {
4444 // - `npm install --install-strategy=shallow` (`npm install --global-style`)
4545 // - `npm install --install-strategy=nested` (`npm install --legacy-bundling`)
4646 // - berry (nodeLinker: "pnpm")
47+ //
48+ // Returns `node_modules/turbo/node_modules` — the caller appends
49+ // package-specific segments. This works for both legacy
50+ // (`turbo-{platform}`) and scoped (`@turbo/{platform}`) packages since
51+ // `join_components` handles multi-segment paths correctly.
4752 fn generate_nested_path ( root_path : & AbsoluteSystemPath ) -> Option < AbsoluteSystemPathBuf > {
4853 Some ( root_path. join_components ( & [ "node_modules" , "turbo" , "node_modules" ] ) )
4954 }
@@ -109,22 +114,22 @@ impl LocalTurboState {
109114 root_path. as_path ( ) . join ( yarn_rc. pnp_unplugged_folder )
110115 }
111116
112- // Unplugged strategy:
113- // - berry 2.1+
114- fn generate_unplugged_path ( root_path : & AbsoluteSystemPath ) -> Option < AbsoluteSystemPathBuf > {
115- let platform_package_name = TurboState :: platform_package_name ( ) ;
116- let unplugged_base_path = Self :: get_unplugged_base_path ( root_path) ;
117-
117+ // Unplugged strategy (Berry 2.1+): Berry encodes the package identity in
118+ // the unplugged directory name. For scoped `@turbo/linux-64` the dir is
119+ // `@turbo-linux-64-npm-{version}-{hash}`, for legacy `turbo-linux-64` it
120+ // is `turbo-linux-64-npm-{version}-{hash}`.
121+ fn find_in_unplugged (
122+ unplugged_base_path : & Utf8PathBuf ,
123+ package_prefix : & str ,
124+ ) -> Option < AbsoluteSystemPathBuf > {
118125 unplugged_base_path
119126 . read_dir_utf8 ( )
120127 . ok ( )
121128 . and_then ( |mut read_dir| {
122- // berry includes additional metadata in the filename.
123- // We actually have to find the platform package.
124129 read_dir. find_map ( |item| match item {
125130 Ok ( entry) => {
126131 let file_name = entry. file_name ( ) ;
127- if file_name. starts_with ( platform_package_name ) {
132+ if file_name. starts_with ( package_prefix ) {
128133 AbsoluteSystemPathBuf :: new (
129134 unplugged_base_path. join ( file_name) . join ( "node_modules" ) ,
130135 )
@@ -138,54 +143,114 @@ impl LocalTurboState {
138143 } )
139144 }
140145
141- // We support six per-platform packages and one `turbo` package which handles
142- // indirection. We identify the per-platform package and execute the appropriate
143- // binary directly. We can choose to operate this aggressively because the
144- // _worst_ outcome is that we run global `turbo`.
146+ /// Try to resolve a local turbo binary at a specific root + package path.
147+ /// Returns `None` if the binary doesn't exist or the package metadata is
148+ /// unreadable — the caller should try the next candidate.
149+ fn try_probe_binary (
150+ root : & AbsoluteSystemPath ,
151+ package_path : & [ & str ] ,
152+ binary_name : & str ,
153+ ) -> Option < Self > {
154+ let mut bin_components: Vec < & str > = Vec :: with_capacity ( package_path. len ( ) + 2 ) ;
155+ bin_components. extend_from_slice ( package_path) ;
156+ bin_components. extend_from_slice ( & [ "bin" , binary_name] ) ;
157+
158+ let bin_path = root. join_components ( & bin_components) ;
159+ let bin_path = match fs_canonicalize ( & bin_path) {
160+ Ok ( p) => p,
161+ Err ( _) => {
162+ debug ! ( "No local turbo binary found at: {}" , bin_path) ;
163+ return None ;
164+ }
165+ } ;
166+
167+ let mut json_components: Vec < & str > = Vec :: with_capacity ( package_path. len ( ) + 1 ) ;
168+ json_components. extend_from_slice ( package_path) ;
169+ json_components. push ( "package.json" ) ;
170+ let resolved_package_json_path = root. join_components ( & json_components) ;
171+
172+ let Some ( platform_package_json) = PackageJson :: load ( & resolved_package_json_path) . ok ( )
173+ else {
174+ debug ! (
175+ "Failed to load package.json at: {}" ,
176+ resolved_package_json_path
177+ ) ;
178+ return None ;
179+ } ;
180+ let Some ( local_version) = platform_package_json. version else {
181+ debug ! (
182+ "No version field in package.json at: {}" ,
183+ resolved_package_json_path
184+ ) ;
185+ return None ;
186+ } ;
187+
188+ debug ! ( "Local turbo path: {}" , bin_path. display( ) ) ;
189+ debug ! ( "Local turbo version: {}" , & local_version) ;
190+ Some ( Self {
191+ bin_path,
192+ version : local_version,
193+ } )
194+ }
195+
196+ // We support twelve per-platform packages (six scoped `@turbo/{platform}`
197+ // and six legacy `turbo-{platform}`) and one `turbo` package which handles
198+ // indirection. We identify the per-platform package and execute the
199+ // appropriate binary directly. We can choose to operate this aggressively
200+ // because the _worst_ outcome is that we run global `turbo`.
145201 //
146- // In spite of that, the only known unsupported local invocation is Yarn/Berry <
147- // 2.1 PnP
202+ // In spite of that, the only known unsupported local invocation is
203+ // Yarn/Berry < 2.1 PnP
148204 pub fn infer ( root_path : & AbsoluteSystemPath ) -> Option < Self > {
149- let platform_package_name = TurboState :: platform_package_name ( ) ;
150205 let binary_name = TurboState :: binary_name ( ) ;
151206
152- let platform_package_json_path_components = [ platform_package_name, "package.json" ] ;
153- let platform_package_executable_path_components =
154- [ platform_package_name, "bin" , binary_name] ;
207+ // Prefer scoped `@turbo/{platform}` over legacy `turbo-{platform}`.
208+ // Scoped packages are the canonical format going forward; legacy is
209+ // retained for backward compatibility.
210+ let scoped_path: & [ & str ] = & [
211+ TurboState :: scoped_platform_package_scope ( ) ,
212+ TurboState :: scoped_platform_package_dir ( ) ,
213+ ] ;
214+ let legacy_path: & [ & str ] = & [ TurboState :: platform_package_name ( ) ] ;
215+ let package_paths: & [ & [ & str ] ] = & [ scoped_path, legacy_path] ;
155216
156- // These are lazy because the last two are more expensive.
217+ // Ordered cheap-to-expensive: hoisted/nested are pure path joins,
218+ // linked requires symlink resolution, unplugged requires directory
219+ // scanning. Detecting the package manager is more expensive than
220+ // exhaustive search.
157221 let search_functions = [
158222 Self :: generate_hoisted_path,
159223 Self :: generate_nested_path,
160224 Self :: generate_linked_path,
161- Self :: generate_unplugged_path,
162225 ] ;
163226
164- // Detecting the package manager is more expensive than just doing an exhaustive
165- // search.
227+ // For each root, try all package formats before moving to the next
228+ // (more expensive) strategy. This avoids redundant filesystem work
229+ // compared to exhausting all roots per format.
166230 for root in search_functions
167231 . iter ( )
168232 . filter_map ( |search_function| search_function ( root_path) )
169233 {
170- // Needs borrow because of the loop.
171- #[ allow( clippy:: needless_borrow) ]
172- let bin_path = root. join_components ( & platform_package_executable_path_components) ;
173- match fs_canonicalize ( & bin_path) {
174- Ok ( bin_path) => {
175- let resolved_package_json_path =
176- root. join_components ( & platform_package_json_path_components) ;
177- let platform_package_json =
178- PackageJson :: load ( & resolved_package_json_path) . ok ( ) ?;
179- let local_version = platform_package_json. version ?;
180-
181- debug ! ( "Local turbo path: {}" , bin_path. display( ) ) ;
182- debug ! ( "Local turbo version: {}" , & local_version) ;
183- return Some ( Self {
184- bin_path,
185- version : local_version,
186- } ) ;
234+ for package_path in package_paths {
235+ if let Some ( state) = Self :: try_probe_binary ( & root, package_path, binary_name) {
236+ return Some ( state) ;
187237 }
188- Err ( _) => debug ! ( "No local turbo binary found at: {}" , bin_path) ,
238+ }
239+ }
240+
241+ // Unplugged strategy (Berry 2.1+): directory scanning is
242+ // package-name-aware because Berry encodes the identity in the
243+ // directory name. Read the unplugged base path once to avoid
244+ // re-parsing .yarnrc.yml.
245+ let unplugged_base_path = Self :: get_unplugged_base_path ( root_path) ;
246+ for package_path in package_paths {
247+ // Berry unplugged dirs use `{name}-npm-{version}-{hash}`.
248+ // For scoped `@turbo/linux-64` this becomes `@turbo-linux-64-npm-...`.
249+ let unplugged_prefix = package_path. join ( "-" ) ;
250+ if let Some ( root) = Self :: find_in_unplugged ( & unplugged_base_path, & unplugged_prefix)
251+ && let Some ( state) = Self :: try_probe_binary ( & root, package_path, binary_name)
252+ {
253+ return Some ( state) ;
189254 }
190255 }
191256
@@ -233,6 +298,7 @@ pub fn turbo_version_has_shim(version: &str) -> bool {
233298
234299#[ cfg( test) ]
235300mod test {
301+ use tempfile:: TempDir ;
236302 use test_case:: test_case;
237303
238304 use super :: * ;
@@ -252,4 +318,144 @@ mod test {
252318 fn test_skip_infer_version_constraint ( version : & str , expected : bool ) {
253319 assert_eq ! ( turbo_version_has_shim( version) , expected) ;
254320 }
321+
322+ fn create_mock_turbo_install ( root : & AbsoluteSystemPath , package_path : & [ & str ] , version : & str ) {
323+ let binary_name = TurboState :: binary_name ( ) ;
324+
325+ let mut bin_components: Vec < & str > = package_path. to_vec ( ) ;
326+ bin_components. extend_from_slice ( & [ "bin" , binary_name] ) ;
327+ let bin_file = root. join_components ( & bin_components) ;
328+ bin_file. ensure_dir ( ) . unwrap ( ) ;
329+ bin_file. create_with_contents ( "" ) . unwrap ( ) ;
330+
331+ let mut json_components: Vec < & str > = package_path. to_vec ( ) ;
332+ json_components. push ( "package.json" ) ;
333+ let json_file = root. join_components ( & json_components) ;
334+ json_file. ensure_dir ( ) . unwrap ( ) ;
335+ json_file
336+ . create_with_contents ( format ! (
337+ r#"{{"name": "test-turbo", "version": "{}"}}"# ,
338+ version,
339+ ) )
340+ . unwrap ( ) ;
341+ }
342+
343+ fn scoped_path ( ) -> Vec < & ' static str > {
344+ vec ! [
345+ TurboState :: scoped_platform_package_scope( ) ,
346+ TurboState :: scoped_platform_package_dir( ) ,
347+ ]
348+ }
349+
350+ fn legacy_path ( ) -> Vec < & ' static str > {
351+ vec ! [ TurboState :: platform_package_name( ) ]
352+ }
353+
354+ #[ test]
355+ fn test_infer_hoisted_scoped ( ) {
356+ let tmpdir = TempDir :: with_prefix ( "turbo_infer" ) . unwrap ( ) ;
357+ let root = AbsoluteSystemPath :: from_std_path ( tmpdir. path ( ) ) . unwrap ( ) ;
358+ let nm = root. join_component ( "node_modules" ) ;
359+
360+ create_mock_turbo_install ( & nm, & scoped_path ( ) , "2.0.0" ) ;
361+
362+ let result = LocalTurboState :: infer ( root) . unwrap ( ) ;
363+ assert_eq ! ( result. version( ) , "2.0.0" ) ;
364+ }
365+
366+ #[ test]
367+ fn test_infer_hoisted_legacy_fallback ( ) {
368+ let tmpdir = TempDir :: with_prefix ( "turbo_infer" ) . unwrap ( ) ;
369+ let root = AbsoluteSystemPath :: from_std_path ( tmpdir. path ( ) ) . unwrap ( ) ;
370+ let nm = root. join_component ( "node_modules" ) ;
371+
372+ create_mock_turbo_install ( & nm, & legacy_path ( ) , "1.9.0" ) ;
373+
374+ let result = LocalTurboState :: infer ( root) . unwrap ( ) ;
375+ assert_eq ! ( result. version( ) , "1.9.0" ) ;
376+ }
377+
378+ #[ test]
379+ fn test_infer_scoped_preferred_over_legacy ( ) {
380+ let tmpdir = TempDir :: with_prefix ( "turbo_infer" ) . unwrap ( ) ;
381+ let root = AbsoluteSystemPath :: from_std_path ( tmpdir. path ( ) ) . unwrap ( ) ;
382+ let nm = root. join_component ( "node_modules" ) ;
383+
384+ create_mock_turbo_install ( & nm, & scoped_path ( ) , "3.0.0" ) ;
385+ create_mock_turbo_install ( & nm, & legacy_path ( ) , "2.0.0" ) ;
386+
387+ let result = LocalTurboState :: infer ( root) . unwrap ( ) ;
388+ assert_eq ! ( result. version( ) , "3.0.0" ) ;
389+ }
390+
391+ #[ test]
392+ fn test_infer_empty_dir_returns_none ( ) {
393+ let tmpdir = TempDir :: with_prefix ( "turbo_infer" ) . unwrap ( ) ;
394+ let root = AbsoluteSystemPath :: from_std_path ( tmpdir. path ( ) ) . unwrap ( ) ;
395+ assert ! ( LocalTurboState :: infer( root) . is_none( ) ) ;
396+ }
397+
398+ #[ test]
399+ fn test_infer_malformed_package_json_continues_search ( ) {
400+ let tmpdir = TempDir :: with_prefix ( "turbo_infer" ) . unwrap ( ) ;
401+ let root = AbsoluteSystemPath :: from_std_path ( tmpdir. path ( ) ) . unwrap ( ) ;
402+ let nm = root. join_component ( "node_modules" ) ;
403+
404+ let scoped = scoped_path ( ) ;
405+ let binary_name = TurboState :: binary_name ( ) ;
406+
407+ // Create scoped binary but with invalid package.json
408+ let mut bin_components: Vec < & str > = scoped. clone ( ) ;
409+ bin_components. extend_from_slice ( & [ "bin" , binary_name] ) ;
410+ let bin_file = nm. join_components ( & bin_components) ;
411+ bin_file. ensure_dir ( ) . unwrap ( ) ;
412+ bin_file. create_with_contents ( "" ) . unwrap ( ) ;
413+
414+ let mut json_components: Vec < & str > = scoped. clone ( ) ;
415+ json_components. push ( "package.json" ) ;
416+ let json_file = nm. join_components ( & json_components) ;
417+ json_file. ensure_dir ( ) . unwrap ( ) ;
418+ json_file. create_with_contents ( "not valid json" ) . unwrap ( ) ;
419+
420+ // Create valid legacy install
421+ create_mock_turbo_install ( & nm, & legacy_path ( ) , "1.8.0" ) ;
422+
423+ // Should fall through to legacy despite scoped binary existing
424+ let result = LocalTurboState :: infer ( root) . unwrap ( ) ;
425+ assert_eq ! ( result. version( ) , "1.8.0" ) ;
426+ }
427+
428+ #[ test]
429+ fn test_infer_unplugged_scoped ( ) {
430+ let tmpdir = TempDir :: with_prefix ( "turbo_infer" ) . unwrap ( ) ;
431+ let root = AbsoluteSystemPath :: from_std_path ( tmpdir. path ( ) ) . unwrap ( ) ;
432+
433+ // Berry unplugged dirs use `{identity}-npm-{version}-{hash}`.
434+ // For @turbo/linux-64 → @turbo-linux-64-npm-2.1.0-abc123
435+ let scoped_dir = TurboState :: scoped_platform_package_dir ( ) ;
436+ let unplugged_dir_name = format ! ( "@turbo-{}-npm-2.1.0-abc123" , scoped_dir) ;
437+
438+ let unplugged_nm =
439+ root. join_components ( & [ ".yarn" , "unplugged" , & unplugged_dir_name, "node_modules" ] ) ;
440+ create_mock_turbo_install ( & unplugged_nm, & scoped_path ( ) , "2.1.0" ) ;
441+
442+ let result = LocalTurboState :: infer ( root) . unwrap ( ) ;
443+ assert_eq ! ( result. version( ) , "2.1.0" ) ;
444+ }
445+
446+ #[ test]
447+ fn test_infer_unplugged_legacy ( ) {
448+ let tmpdir = TempDir :: with_prefix ( "turbo_infer" ) . unwrap ( ) ;
449+ let root = AbsoluteSystemPath :: from_std_path ( tmpdir. path ( ) ) . unwrap ( ) ;
450+
451+ let platform_package_name = TurboState :: platform_package_name ( ) ;
452+ let unplugged_dir_name = format ! ( "{}-npm-1.9.0-def456" , platform_package_name) ;
453+
454+ let unplugged_nm =
455+ root. join_components ( & [ ".yarn" , "unplugged" , & unplugged_dir_name, "node_modules" ] ) ;
456+ create_mock_turbo_install ( & unplugged_nm, & legacy_path ( ) , "1.9.0" ) ;
457+
458+ let result = LocalTurboState :: infer ( root) . unwrap ( ) ;
459+ assert_eq ! ( result. version( ) , "1.9.0" ) ;
460+ }
255461}
0 commit comments