@@ -3,6 +3,31 @@ use libsemverator::range::Range as VersionReq;
33use std:: collections:: { HashMap , HashSet } ;
44use std:: error:: Error ;
55
6+ /// Projects whose distinct version lines are parallel-installable (different
7+ /// sonames / ICU majors / abseil LTS namespaces). When constraints cannot
8+ /// intersect we keep multiple nodes instead of failing the graph.
9+ ///
10+ /// - unicode.org: ICU major ABI (see pantry#4104, pkgx#899)
11+ /// - openssl.org: libssl.so.1.1 vs libssl.so.3
12+ /// - abseil.io: LTS inline-namespace + soversion (20250127 vs 20250512, …)
13+ const MULTI_VERSION_PROJECTS : & [ & str ] = & [ "unicode.org" , "openssl.org" , "abseil.io" ] ;
14+
15+ fn is_multi_version ( project : & str ) -> bool {
16+ MULTI_VERSION_PROJECTS . contains ( & project)
17+ }
18+
19+ /// Record an extra constraint for a multi-version project, merging into an
20+ /// existing additional entry when the ranges intersect.
21+ fn push_additional ( additional : & mut Vec < PackageReq > , pkg : PackageReq ) {
22+ for existing in additional. iter_mut ( ) . filter ( |p| p. project == pkg. project ) {
23+ if let Ok ( constraint) = intersect_constraints ( & existing. constraint , & pkg. constraint ) {
24+ existing. constraint = constraint;
25+ return ;
26+ }
27+ }
28+ additional. push ( pkg) ;
29+ }
30+
631#[ derive( Clone ) ]
732struct Node {
833 parent : Option < Box < Node > > ,
@@ -38,36 +63,57 @@ pub async fn hydrate<F>(
3863where
3964 F : Fn ( String ) -> Result < Vec < PackageReq > , Box < dyn Error > > ,
4065{
41- let dry = condense ( input) ;
66+ let dry = condense ( input) ? ;
4267 let mut graph: HashMap < String , Box < Node > > = HashMap :: new ( ) ;
4368 let mut stack: Vec < Box < Node > > = vec ! [ ] ;
44- let mut additional_unicodes : Vec < VersionReq > = vec ! [ ] ;
69+ let mut additional : Vec < PackageReq > = vec ! [ ] ;
4570
4671 for pkg in dry. iter ( ) {
47- let node = graph
48- . entry ( pkg. project . clone ( ) )
49- . or_insert_with ( || Box :: new ( Node :: new ( pkg. clone ( ) , None ) ) ) ;
50- node. pkg . constraint = intersect_constraints ( & node. pkg . constraint , & pkg. constraint )
51- . map_err ( |e| format ! ( "{} for {}" , e, pkg. project) ) ?;
52- stack. push ( node. clone ( ) ) ;
72+ if let Some ( node) = graph. get_mut ( & pkg. project ) {
73+ match intersect_constraints ( & node. pkg . constraint , & pkg. constraint ) {
74+ Ok ( constraint) => {
75+ node. pkg . constraint = constraint;
76+ stack. push ( node. clone ( ) ) ;
77+ }
78+ Err ( e) => {
79+ if is_multi_version ( & pkg. project ) {
80+ push_additional ( & mut additional, pkg. clone ( ) ) ;
81+ } else {
82+ return Err ( format ! ( "{} for {}" , e, pkg. project) . into ( ) ) ;
83+ }
84+ }
85+ }
86+ } else {
87+ let node = Box :: new ( Node :: new ( pkg. clone ( ) , None ) ) ;
88+ graph. insert ( pkg. project . clone ( ) , node. clone ( ) ) ;
89+ stack. push ( node) ;
90+ }
5391 }
5492
5593 while let Some ( mut current) = stack. pop ( ) {
5694 for child_pkg in get_deps ( current. pkg . project . clone ( ) ) ? {
95+ let was_new = !graph. contains_key ( & child_pkg. project ) ;
5796 let child_node = graph
5897 . entry ( child_pkg. project . clone ( ) )
5998 . or_insert_with ( || Box :: new ( Node :: new ( child_pkg. clone ( ) , Some ( current. clone ( ) ) ) ) ) ;
99+
100+ if was_new {
101+ // Fresh node already carries child_pkg.constraint.
102+ current. children . insert ( child_node. pkg . project . clone ( ) ) ;
103+ stack. push ( child_node. clone ( ) ) ;
104+ continue ;
105+ }
106+
107+ // Already have a graph node: try the primary constraint, then any
108+ // additional lines for this multi-version project.
60109 let intersection =
61110 intersect_constraints ( & child_node. pkg . constraint , & child_pkg. constraint ) ;
62111 if let Ok ( constraint) = intersection {
63112 child_node. pkg . constraint = constraint;
64113 current. children . insert ( child_node. pkg . project . clone ( ) ) ;
65114 stack. push ( child_node. clone ( ) ) ;
66- } else if child_pkg. project == "unicode.org" {
67- // we handle unicode.org for now to allow situations like:
68- // https://github.com/pkgxdev/pantry/issues/4104
69- // https://github.com/pkgxdev/pkgx/issues/899
70- additional_unicodes. push ( child_pkg. constraint ) ;
115+ } else if is_multi_version ( & child_pkg. project ) {
116+ push_additional ( & mut additional, child_pkg) ;
71117 } else {
72118 return Err (
73119 format ! ( "{} for {}" , intersection. unwrap_err( ) , child_pkg. project) . into ( ) ,
@@ -80,33 +126,210 @@ where
80126 pkgs. sort_by_key ( |node| node. count ( ) ) ;
81127 let mut pkgs: Vec < PackageReq > = pkgs. into_iter ( ) . map ( |node| node. pkg . clone ( ) ) . collect ( ) ;
82128
83- // see above explanation
84- for constraint in additional_unicodes {
85- let pkg = PackageReq {
86- project : "unicode.org" . to_string ( ) ,
87- constraint,
88- } ;
89- pkgs. push ( pkg) ;
90- }
129+ pkgs. extend ( additional) ;
91130
92131 Ok ( pkgs)
93132}
94133
95134/// Condenses a list of `PackageRequirement` by intersecting constraints for duplicates.
96- fn condense ( pkgs : & Vec < PackageReq > ) -> Vec < PackageReq > {
135+ /// Multi-version projects keep non-intersecting constraints as separate entries.
136+ fn condense ( pkgs : & Vec < PackageReq > ) -> Result < Vec < PackageReq > , Box < dyn Error > > {
97137 let mut out: Vec < PackageReq > = vec ! [ ] ;
98138 for pkg in pkgs {
99139 if let Some ( existing) = out. iter_mut ( ) . find ( |p| p. project == pkg. project ) {
100- existing. constraint = intersect_constraints ( & existing. constraint , & pkg. constraint )
101- . expect ( "Failed to intersect constraints" ) ;
140+ match intersect_constraints ( & existing. constraint , & pkg. constraint ) {
141+ Ok ( constraint) => existing. constraint = constraint,
142+ Err ( e) => {
143+ if is_multi_version ( & pkg. project ) {
144+ // merge into a later non-intersecting sibling if possible
145+ let mut merged = false ;
146+ for sibling in out. iter_mut ( ) . filter ( |p| p. project == pkg. project ) . skip ( 1 ) {
147+ if let Ok ( constraint) =
148+ intersect_constraints ( & sibling. constraint , & pkg. constraint )
149+ {
150+ sibling. constraint = constraint;
151+ merged = true ;
152+ break ;
153+ }
154+ }
155+ if !merged {
156+ out. push ( pkg. clone ( ) ) ;
157+ }
158+ } else {
159+ return Err ( format ! ( "{} for {}" , e, pkg. project) . into ( ) ) ;
160+ }
161+ }
162+ }
102163 } else {
103164 out. push ( pkg. clone ( ) ) ;
104165 }
105166 }
106- out
167+ Ok ( out)
107168}
108169
109170/// Intersects two version constraints.
110171fn intersect_constraints ( a : & VersionReq , b : & VersionReq ) -> Result < VersionReq , Box < dyn Error > > {
111172 a. intersect ( b) . map_err ( |e| e. into ( ) )
112173}
174+
175+ #[ cfg( test) ]
176+ mod tests {
177+ use super :: * ;
178+
179+ fn req ( project : & str , constraint : & str ) -> PackageReq {
180+ PackageReq {
181+ project : project. to_string ( ) ,
182+ constraint : VersionReq :: parse ( constraint) . unwrap ( ) ,
183+ }
184+ }
185+
186+ fn pkgs_for < ' a > ( pkgs : & ' a [ PackageReq ] , project : & str ) -> Vec < & ' a PackageReq > {
187+ pkgs. iter ( ) . filter ( |p| p. project == project) . collect ( )
188+ }
189+
190+ /// True if some hydrated line for `project` intersects `range` (same ABI line).
191+ fn has_line ( pkgs : & [ PackageReq ] , project : & str , range : & str ) -> bool {
192+ let want = VersionReq :: parse ( range) . unwrap ( ) ;
193+ pkgs_for ( pkgs, project)
194+ . into_iter ( )
195+ . any ( |p| p. constraint . intersect ( & want) . is_ok ( ) )
196+ }
197+
198+ /// Assert two ranges remain disjoint (cannot be collapsed).
199+ fn assert_disjoint ( a : & str , b : & str ) {
200+ assert ! ( VersionReq :: parse( a)
201+ . unwrap( )
202+ . intersect( & VersionReq :: parse( b) . unwrap( ) )
203+ . is_err( ) ) ;
204+ }
205+
206+ #[ tokio:: test]
207+ async fn hydrates_unicode_multi ( ) {
208+ let input = vec ! [ req( "npmjs.com" , "*" ) , req( "python.org" , "~3.9" ) ] ;
209+ let pkgs = hydrate ( & input, |project| match project. as_str ( ) {
210+ "python.org" => Ok ( vec ! [ req( "unicode.org" , "^73" ) ] ) ,
211+ "npmjs.com" => Ok ( vec ! [ req( "unicode.org" , "^71" ) ] ) ,
212+ _ => Ok ( vec ! [ ] ) ,
213+ } )
214+ . await
215+ . unwrap ( ) ;
216+
217+ assert_eq ! ( pkgs_for( & pkgs, "unicode.org" ) . len( ) , 2 ) ;
218+ assert ! ( has_line( & pkgs, "unicode.org" , "^71" ) ) ;
219+ assert ! ( has_line( & pkgs, "unicode.org" , "^73" ) ) ;
220+ assert_disjoint ( "^71" , "^73" ) ;
221+ }
222+
223+ #[ tokio:: test]
224+ async fn hydrates_openssl_multi ( ) {
225+ // python locks ^1.1; cryptography needs ^3 — must coexist
226+ let input = vec ! [ req( "python.org" , "*" ) , req( "cryptography.io" , "*" ) ] ;
227+ let pkgs = hydrate ( & input, |project| match project. as_str ( ) {
228+ "python.org" => Ok ( vec ! [ req( "openssl.org" , "^1.1" ) ] ) ,
229+ "cryptography.io" => Ok ( vec ! [ req( "openssl.org" , "^3" ) ] ) ,
230+ _ => Ok ( vec ! [ ] ) ,
231+ } )
232+ . await
233+ . unwrap ( ) ;
234+
235+ assert_eq ! ( pkgs_for( & pkgs, "openssl.org" ) . len( ) , 2 ) ;
236+ assert ! ( has_line( & pkgs, "openssl.org" , "^1.1" ) ) ;
237+ assert ! ( has_line( & pkgs, "openssl.org" , "^3" ) ) ;
238+ assert_disjoint ( "^1.1" , "^3" ) ;
239+ }
240+
241+ #[ tokio:: test]
242+ async fn hydrates_abseil_multi ( ) {
243+ // re2 on one LTS line, grpc on another
244+ let input = vec ! [ req( "github.com/google/re2" , "*" ) , req( "grpc.io" , "*" ) ] ;
245+ let pkgs = hydrate ( & input, |project| match project. as_str ( ) {
246+ "github.com/google/re2" => Ok ( vec ! [ req( "abseil.io" , "^20250127" ) ] ) ,
247+ "grpc.io" => Ok ( vec ! [ req( "abseil.io" , ">=20250512" ) ] ) ,
248+ _ => Ok ( vec ! [ ] ) ,
249+ } )
250+ . await
251+ . unwrap ( ) ;
252+
253+ assert_eq ! ( pkgs_for( & pkgs, "abseil.io" ) . len( ) , 2 ) ;
254+ assert ! ( has_line( & pkgs, "abseil.io" , "^20250127" ) ) ;
255+ assert ! ( has_line( & pkgs, "abseil.io" , ">=20250512" ) ) ;
256+ assert_disjoint ( "^20250127" , ">=20250512" ) ;
257+ }
258+
259+ #[ tokio:: test]
260+ async fn hydrates_openssl_dry_input ( ) {
261+ // explicit +openssl^1.1 +openssl^3
262+ let input = vec ! [ req( "openssl.org" , "^1.1" ) , req( "openssl.org" , "^3" ) ] ;
263+ let pkgs = hydrate ( & input, |_| Ok ( vec ! [ ] ) ) . await . unwrap ( ) ;
264+
265+ assert_eq ! ( pkgs_for( & pkgs, "openssl.org" ) . len( ) , 2 ) ;
266+ assert ! ( has_line( & pkgs, "openssl.org" , "^1.1" ) ) ;
267+ assert ! ( has_line( & pkgs, "openssl.org" , "^3" ) ) ;
268+ }
269+
270+ #[ tokio:: test]
271+ async fn hydrates_multi_version_three_way ( ) {
272+ // three consumers, two openssl lines (two share ^1.1, one needs ^3)
273+ // order cryptography first so ^3 is the graph node and both ^1.1 merge
274+ // into a single additional entry.
275+ let input = vec ! [
276+ req( "cryptography.io" , "*" ) ,
277+ req( "python.org" , "*" ) ,
278+ req( "curl.se" , "*" ) ,
279+ ] ;
280+ let pkgs = hydrate ( & input, |project| match project. as_str ( ) {
281+ "python.org" | "curl.se" => Ok ( vec ! [ req( "openssl.org" , "^1.1" ) ] ) ,
282+ "cryptography.io" => Ok ( vec ! [ req( "openssl.org" , "^3" ) ] ) ,
283+ _ => Ok ( vec ! [ ] ) ,
284+ } )
285+ . await
286+ . unwrap ( ) ;
287+
288+ assert_eq ! ( pkgs_for( & pkgs, "openssl.org" ) . len( ) , 2 ) ;
289+ assert ! ( has_line( & pkgs, "openssl.org" , "^1.1" ) ) ;
290+ assert ! ( has_line( & pkgs, "openssl.org" , "^3" ) ) ;
291+ }
292+
293+ #[ tokio:: test]
294+ async fn hydrates_cannot_intersect ( ) {
295+ let input = vec ! [ req( "npmjs.com" , "*" ) , req( "python.org" , "~3.9" ) ] ;
296+ let err = hydrate ( & input, |project| match project. as_str ( ) {
297+ "python.org" => Ok ( vec ! [ req( "nodejs.com" , "^73" ) ] ) ,
298+ "npmjs.com" => Ok ( vec ! [ req( "nodejs.com" , "^71" ) ] ) ,
299+ _ => Ok ( vec ! [ ] ) ,
300+ } )
301+ . await
302+ . unwrap_err ( ) ;
303+
304+ assert ! ( err. to_string( ) . contains( "nodejs.com" ) ) ;
305+ }
306+
307+ #[ tokio:: test]
308+ async fn hydrates_compatible_intersect ( ) {
309+ let input = vec ! [ req( "pipenv.pypa.io" , "*" ) , req( "python.org" , "~3.9" ) ] ;
310+ let pkgs = hydrate ( & input, |project| match project. as_str ( ) {
311+ "pipenv.pypa.io" => Ok ( vec ! [ req( "python.org" , ">=3.7" ) ] ) ,
312+ _ => Ok ( vec ! [ ] ) ,
313+ } )
314+ . await
315+ . unwrap ( ) ;
316+
317+ let pythons = pkgs_for ( & pkgs, "python.org" ) ;
318+ assert_eq ! ( pythons. len( ) , 1 ) ;
319+ // dry ~3.9 wins over looser >=3.7
320+ assert ! ( has_line( & pkgs, "python.org" , "~3.9" ) ) ;
321+ assert ! ( !has_line( & pkgs, "python.org" , "~3.10" ) ) ;
322+ assert ! ( !has_line( & pkgs, "python.org" , "~3.8" ) ) ;
323+ }
324+
325+ #[ tokio:: test]
326+ async fn hydrates_multi_version_dry_condense_compatible ( ) {
327+ // two compatible dry openssl constraints still collapse to one
328+ let input = vec ! [ req( "openssl.org" , "^1.1" ) , req( "openssl.org" , ">=1.1.1" ) ] ;
329+ let pkgs = hydrate ( & input, |_| Ok ( vec ! [ ] ) ) . await . unwrap ( ) ;
330+
331+ assert_eq ! ( pkgs_for( & pkgs, "openssl.org" ) . len( ) , 1 ) ;
332+ assert ! ( has_line( & pkgs, "openssl.org" , "^1.1" ) ) ;
333+ assert ! ( !has_line( & pkgs, "openssl.org" , "^3" ) ) ;
334+ }
335+ }
0 commit comments