@@ -7,10 +7,12 @@ use cargo_metadata::{MetadataCommand, PackageId};
77use globset:: { Glob , GlobSet , GlobSetBuilder } ;
88use semver:: Version ;
99use serde:: Serialize ;
10+ use tracing:: warn;
1011
1112use crate :: errors:: { CargoMonoError , ErrorKind , Result } ;
1213
1314pub const GLOBAL_IMPACT_FILES : [ & str ; 3 ] = [ "Cargo.toml" , "Cargo.lock" , "rust-toolchain" ] ;
15+ const DEPENDENCY_SCOPE_ALL_CARGO_METADATA_KINDS : & str = "all-cargo-metadata-kinds" ;
1416
1517#[ derive( Debug , Clone , Serialize ) ]
1618pub struct WorkspacePackage {
@@ -302,21 +304,165 @@ impl Workspace {
302304 if ordered. len ( ) != selected. len ( ) {
303305 let selected_names = selected. iter ( ) . cloned ( ) . collect :: < Vec < _ > > ( ) ;
304306 let selected_sample = format_string_sample ( & selected_names, 8 ) ;
307+ let unresolved_names = indegree
308+ . iter ( )
309+ . filter_map ( |( name, degree) | {
310+ if * degree > 0 {
311+ Some ( name. clone ( ) )
312+ } else {
313+ None
314+ }
315+ } )
316+ . collect :: < Vec < _ > > ( ) ;
317+ let unresolved_sample = format_string_sample ( & unresolved_names, 8 ) ;
318+ let unresolved = unresolved_names. iter ( ) . cloned ( ) . collect :: < BTreeSet < _ > > ( ) ;
319+ let cycle_packages = self . cycle_packages_for_nodes ( & unresolved) ;
320+ let cycle_packages_sample = format_string_sample ( & cycle_packages, 8 ) ;
321+
322+ warn ! (
323+ command_path = "cargo-mono.workspace" ,
324+ workspace_root = %self . root. display( ) ,
325+ action = "build-topological-order" ,
326+ outcome = "failed-cycle" ,
327+ selected_count = selected. len( ) ,
328+ unresolved_count = unresolved_names. len( ) ,
329+ cycle_package_count = cycle_packages. len( ) ,
330+ dependency_scope = DEPENDENCY_SCOPE_ALL_CARGO_METADATA_KINDS ,
331+ selected_sample = %selected_sample,
332+ unresolved_sample = %unresolved_sample,
333+ cycle_packages = %cycle_packages_sample,
334+ "Failed to build package order due to a dependency cycle in selected packages"
335+ ) ;
336+
305337 return Err ( CargoMonoError :: with_details (
306338 ErrorKind :: Conflict ,
307339 "Failed to build package order due to a dependency cycle in selected packages." ,
308340 vec ! [
309341 ( "selected_count" , selected. len( ) . to_string( ) ) ,
310342 ( "selected_sample" , selected_sample) ,
343+ ( "unresolved_count" , unresolved_names. len( ) . to_string( ) ) ,
344+ ( "unresolved_sample" , unresolved_sample) ,
345+ ( "cycle_package_count" , cycle_packages. len( ) . to_string( ) ) ,
346+ ( "cycle_packages" , cycle_packages_sample) ,
347+ (
348+ "dependency_scope" ,
349+ DEPENDENCY_SCOPE_ALL_CARGO_METADATA_KINDS . to_string( ) ,
350+ ) ,
311351 ] ,
312352 "Break the cycle between selected packages, or narrow the target set with \
313- `--package`/`--changed`.",
353+ `--package`/`--changed`. The strict dependency scope includes normal, build, and \
354+ dev dependencies from cargo metadata.",
314355 ) ) ;
315356 }
316357
317358 Ok ( ordered)
318359 }
319360
361+ fn cycle_packages_for_nodes ( & self , nodes : & BTreeSet < String > ) -> Vec < String > {
362+ let mut detector = CycleDetector :: default ( ) ;
363+ for node in nodes {
364+ if detector. indices . contains_key ( node) {
365+ continue ;
366+ }
367+
368+ self . strong_connect_cycle_node ( node, nodes, & mut detector) ;
369+ }
370+
371+ detector. cycle_packages . into_iter ( ) . collect ( )
372+ }
373+
374+ fn strong_connect_cycle_node (
375+ & self ,
376+ node : & str ,
377+ nodes : & BTreeSet < String > ,
378+ detector : & mut CycleDetector ,
379+ ) {
380+ let node = node. to_string ( ) ;
381+ detector. indices . insert ( node. clone ( ) , detector. next_index ) ;
382+ detector. lowlinks . insert ( node. clone ( ) , detector. next_index ) ;
383+ detector. next_index += 1 ;
384+ detector. stack . push ( node. clone ( ) ) ;
385+ detector. on_stack . insert ( node. clone ( ) ) ;
386+
387+ if let Some ( dependencies) = self . dependencies . get ( & node) {
388+ for dependency in dependencies {
389+ if !nodes. contains ( dependency) {
390+ continue ;
391+ }
392+
393+ if !detector. indices . contains_key ( dependency) {
394+ self . strong_connect_cycle_node ( dependency, nodes, detector) ;
395+ let dependency_lowlink = detector
396+ . lowlinks
397+ . get ( dependency)
398+ . copied ( )
399+ . expect ( "dependency lowlink must exist after DFS traversal" ) ;
400+ let node_lowlink = detector
401+ . lowlinks
402+ . get ( & node)
403+ . copied ( )
404+ . expect ( "node lowlink must exist" ) ;
405+ if dependency_lowlink < node_lowlink {
406+ detector. lowlinks . insert ( node. clone ( ) , dependency_lowlink) ;
407+ }
408+ continue ;
409+ }
410+
411+ if detector. on_stack . contains ( dependency) {
412+ let dependency_index = detector
413+ . indices
414+ . get ( dependency)
415+ . copied ( )
416+ . expect ( "dependency index must exist while on stack" ) ;
417+ let node_lowlink = detector
418+ . lowlinks
419+ . get ( & node)
420+ . copied ( )
421+ . expect ( "node lowlink must exist" ) ;
422+ if dependency_index < node_lowlink {
423+ detector. lowlinks . insert ( node. clone ( ) , dependency_index) ;
424+ }
425+ }
426+ }
427+ }
428+
429+ let node_lowlink = detector
430+ . lowlinks
431+ . get ( & node)
432+ . copied ( )
433+ . expect ( "node lowlink must exist" ) ;
434+ let node_index = detector
435+ . indices
436+ . get ( & node)
437+ . copied ( )
438+ . expect ( "node index must exist" ) ;
439+ if node_lowlink != node_index {
440+ return ;
441+ }
442+
443+ let mut component = Vec :: new ( ) ;
444+ while let Some ( current) = detector. stack . pop ( ) {
445+ detector. on_stack . remove ( & current) ;
446+ component. push ( current. clone ( ) ) ;
447+ if current == node {
448+ break ;
449+ }
450+ }
451+
452+ let contains_cycle = component. len ( ) > 1
453+ || component
454+ . first ( )
455+ . and_then ( |current| {
456+ self . dependencies
457+ . get ( current)
458+ . map ( |deps| deps. contains ( current) )
459+ } )
460+ . unwrap_or ( false ) ;
461+ if contains_cycle {
462+ detector. cycle_packages . extend ( component) ;
463+ }
464+ }
465+
320466 fn normalize_relative_path ( & self , path : & Path ) -> Option < PathBuf > {
321467 if path. is_absolute ( ) {
322468 return path. strip_prefix ( & self . root ) . ok ( ) . map ( Path :: to_path_buf) ;
@@ -355,6 +501,16 @@ impl Workspace {
355501 }
356502}
357503
504+ #[ derive( Debug , Default ) ]
505+ struct CycleDetector {
506+ next_index : usize ,
507+ indices : HashMap < String , usize > ,
508+ lowlinks : HashMap < String , usize > ,
509+ stack : Vec < String > ,
510+ on_stack : BTreeSet < String > ,
511+ cycle_packages : BTreeSet < String > ,
512+ }
513+
358514fn compile_globset ( patterns : & [ String ] , flag : & str ) -> Result < Option < GlobSet > > {
359515 if patterns. is_empty ( ) {
360516 return Ok ( None ) ;
@@ -470,6 +626,29 @@ mod tests {
470626 Workspace :: from_parts ( root, packages, dependencies, dependents)
471627 }
472628
629+ fn cycle_fixture_workspace ( ) -> Workspace {
630+ let root = PathBuf :: from ( "/repo" ) ;
631+ let packages = [ "a" , "b" , "c" ]
632+ . into_iter ( )
633+ . map ( |name| ( name. to_string ( ) , package ( name, & root) ) )
634+ . collect :: < BTreeMap < _ , _ > > ( ) ;
635+
636+ let mut dependencies = BTreeMap :: < String , BTreeSet < String > > :: new ( ) ;
637+ dependencies. insert ( "a" . to_string ( ) , BTreeSet :: from ( [ "b" . to_string ( ) ] ) ) ;
638+ dependencies. insert ( "b" . to_string ( ) , BTreeSet :: from ( [ "a" . to_string ( ) ] ) ) ;
639+ dependencies. insert ( "c" . to_string ( ) , BTreeSet :: from ( [ "a" . to_string ( ) ] ) ) ;
640+
641+ let mut dependents = BTreeMap :: < String , BTreeSet < String > > :: new ( ) ;
642+ dependents. insert (
643+ "a" . to_string ( ) ,
644+ BTreeSet :: from ( [ "b" . to_string ( ) , "c" . to_string ( ) ] ) ,
645+ ) ;
646+ dependents. insert ( "b" . to_string ( ) , BTreeSet :: from ( [ "a" . to_string ( ) ] ) ) ;
647+ dependents. insert ( "c" . to_string ( ) , BTreeSet :: new ( ) ) ;
648+
649+ Workspace :: from_parts ( root, packages, dependencies, dependents)
650+ }
651+
473652 #[ test]
474653 fn changed_packages_maps_direct_paths ( ) {
475654 let workspace = fixture_workspace ( ) ;
@@ -600,4 +779,30 @@ mod tests {
600779 assert ! ( core_index < app_index) ;
601780 assert ! ( core_index < cli_index) ;
602781 }
782+
783+ #[ test]
784+ fn topological_order_cycle_error_reports_cycle_packages_and_scope ( ) {
785+ let workspace = cycle_fixture_workspace ( ) ;
786+ let selected = BTreeSet :: from ( [ "a" . to_string ( ) , "b" . to_string ( ) , "c" . to_string ( ) ] ) ;
787+
788+ let error = workspace
789+ . topological_order ( & selected)
790+ . expect_err ( "expected dependency cycle conflict" ) ;
791+
792+ assert_eq ! ( error. kind, ErrorKind :: Conflict ) ;
793+ assert ! ( error. message. contains(
794+ "Failed to build package order due to a dependency cycle in selected packages."
795+ ) ) ;
796+ assert ! ( error. message. contains( "selected_count=3" ) ) ;
797+ assert ! ( error. message. contains( "unresolved_count=3" ) ) ;
798+ assert ! ( error. message. contains( "cycle_package_count=2" ) ) ;
799+ assert ! ( error. message. contains( "cycle_packages=a|b" ) ) ;
800+ assert ! ( error
801+ . message
802+ . contains( "dependency_scope=all-cargo-metadata-kinds" ) ) ;
803+ assert ! ( error. message. contains(
804+ "strict dependency scope includes normal, build, and dev dependencies from cargo \
805+ metadata"
806+ ) ) ;
807+ }
603808}
0 commit comments