1use crate::constraint::{Constraint, Op};
16use crate::intervals;
17use crate::package::Package;
18use crate::version;
19use serde_json::Value;
20use std::collections::{BTreeMap, HashMap};
21
22#[derive(Debug, Clone, Default, PartialEq)]
24pub struct FlexVersions {
25 pub splits: Vec<(String, Vec<String>)>,
28 pub next: String,
30}
31
32impl FlexVersions {
33 pub fn from_value(v: &Value) -> Result<FlexVersions, String> {
36 let Some(splits) = v.get("splits").and_then(Value::as_object) else {
37 return Err("The Flex index is missing a \"splits\" entry. Did you forget to add \"flex://defaults\" in the \"extra.symfony.endpoint\" array of your composer.json?".to_owned());
38 };
39 let mut out = Vec::new();
40 for (name, vers) in splits {
41 let list: Vec<String> = vers
42 .as_array()
43 .map(|a| {
44 a.iter()
45 .filter_map(Value::as_str)
46 .map(str::to_owned)
47 .collect()
48 })
49 .unwrap_or_default();
50 out.push((name.clone(), list));
51 }
52 Ok(FlexVersions {
53 splits: out,
54 next: v
55 .get("next")
56 .and_then(Value::as_str)
57 .unwrap_or("")
58 .to_owned(),
59 })
60 }
61
62 pub fn merge_endpoints(indexes: &[Value]) -> Value {
65 let mut merged = serde_json::Map::new();
66 for index in indexes {
67 if let Some(v) = index.get("versions").and_then(Value::as_object) {
68 for (k, val) in v {
69 merged.entry(k.clone()).or_insert_with(|| val.clone());
70 }
71 }
72 }
73 Value::Object(merged)
74 }
75
76 pub fn pruned(&self, symfony: &Constraint) -> FlexVersions {
81 let mut ok: HashMap<&str, bool> = HashMap::new();
82 let mut splits = Vec::new();
83 for (name, vers) in &self.splits {
84 let mut kept: Vec<String> = Vec::new();
85 for v in vers {
86 let is_ok = *ok.entry(v.as_str()).or_insert_with(|| {
87 let w = if v.ends_with(".x") {
88 self.next.as_str()
89 } else {
90 v.as_str()
91 };
92 (0..60).any(|j| symfony.matches_version(&norm_or_raw(&format!("{w}.{j}.0"))))
93 });
94 if is_ok {
95 kept.push(v.clone());
96 }
97 }
98 if !kept.is_empty() && &kept != vers {
99 splits.push((name.clone(), kept));
100 }
101 }
102 FlexVersions {
103 splits,
104 next: self.next.clone(),
105 }
106 }
107
108 fn has(&self, name: &str) -> bool {
109 self.splits.iter().any(|(n, _)| n == name)
110 }
111}
112
113fn norm_or_raw(v: &str) -> String {
114 version::normalize(v, None).unwrap_or_else(|_| v.to_owned())
115}
116
117pub fn symfony_require(manifest: &Value) -> Option<String> {
120 let from_env = std::env::var("SYMFONY_REQUIRE")
121 .ok()
122 .filter(|s| !s.is_empty());
123 let raw = from_env.or_else(|| {
124 manifest
125 .get("extra")?
126 .get("symfony")?
127 .get("require")?
128 .as_str()
129 .map(str::to_owned)
130 })?;
131 if raw.is_empty() {
132 return None;
133 }
134 Some(match raw.strip_suffix(".x") {
135 Some(base) => format!("{base}.x-dev"),
136 None => raw,
137 })
138}
139
140pub fn remove_legacy_packages(
151 packages: &[usize],
152 arena: &[Package],
153 root_constraints: &BTreeMap<String, Constraint>,
154 locked: &BTreeMap<String, Vec<String>>,
155 symfony: &Constraint,
156 versions: &FlexVersions,
157) -> (Vec<usize>, bool) {
158 let versions = versions.pruned(symfony);
159 let mut kept: Vec<usize> = Vec::new();
160 let mut symfony_packages: Vec<usize> = Vec::new();
161 let mut one_symfony = false;
162 let mut restricting = false;
163 for &idx in packages {
164 let p = &arena[idx];
165 let name = p.name.as_str();
166 let mut candidate_versions: Vec<String> = vec![p.version.clone()];
167 if let Some(base) = p.alias_of {
168 candidate_versions.push(arena[base].version.clone());
169 }
170 let locked_hit = locked
171 .get(name)
172 .is_some_and(|l| candidate_versions.iter().any(|v| l.contains(v)));
173 let root_disjoint = root_constraints
174 .get(name)
175 .is_some_and(|c| !intervals::have_intersections(symfony, c));
176 let bridge =
177 name == "symfony/psr-http-message-bridge" && php_float(&candidate_versions[0]) < 6.4;
178 if name != "symfony/symfony"
179 && (locked_hit || !versions.has(name) || root_disjoint || bridge)
180 {
181 kept.push(idx);
182 continue;
183 }
184 if let Some(alias) = p
186 .raw
187 .get("extra")
188 .and_then(|e| e.get("branch-alias"))
189 .and_then(|b| b.get(&p.version))
190 .and_then(Value::as_str)
191 {
192 candidate_versions.push(norm_or_raw(alias));
193 }
194 if candidate_versions.iter().any(|v| {
195 symfony.matches(&Constraint::Single {
196 op: Op::Eq,
197 version: v.clone(),
198 })
199 }) {
200 kept.push(idx);
201 one_symfony = one_symfony || name == "symfony/symfony";
202 continue;
203 }
204 if name == "symfony/symfony" {
205 symfony_packages.push(idx);
206 } else {
207 restricting = true;
208 }
209 }
210 if !symfony_packages.is_empty() && !one_symfony {
211 kept.extend(symfony_packages);
212 }
213 (kept, restricting)
214}
215
216fn php_float(version: &str) -> f64 {
219 let mut end = 0;
220 let bytes = version.as_bytes();
221 let mut seen_dot = false;
222 while end < bytes.len() {
223 let b = bytes[end];
224 if b.is_ascii_digit() {
225 end += 1;
226 } else if b == b'.' && !seen_dot {
227 seen_dot = true;
228 end += 1;
229 } else {
230 break;
231 }
232 }
233 version[..end].parse::<f64>().unwrap_or(0.0)
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use crate::constraint::parse_constraints;
240 use crate::package::Origin;
241 use serde_json::json;
242
243 fn cons(s: &str) -> Constraint {
244 parse_constraints(s).expect("constraint").constraint
245 }
246
247 fn pkg(name: &str, version: &str) -> Package {
248 let normalized = norm_or_raw(version);
249 Package::new(name, &normalized, version, Origin::Repository(0))
250 }
251
252 #[test]
253 fn symfony_require_env_wins_and_x_becomes_x_dev() {
254 let m = json!({"extra": {"symfony": {"require": "7.1.*"}}});
255 std::env::remove_var("SYMFONY_REQUIRE");
256 assert_eq!(symfony_require(&m).as_deref(), Some("7.1.*"));
257 let m = json!({"extra": {"symfony": {"require": "7.x"}}});
258 assert_eq!(symfony_require(&m).as_deref(), Some("7.x-dev"));
259 assert_eq!(symfony_require(&json!({})), None);
260 }
261
262 #[test]
263 fn pruning_keeps_the_matching_splits_and_drops_unchanged_entries() {
264 let v = FlexVersions {
265 splits: vec![
266 (
267 "symfony/console".into(),
268 vec!["6.4".into(), "7.0".into(), "7.1".into()],
269 ),
270 ("symfony/only-old".into(), vec!["5.4".into()]),
271 ("symfony/next".into(), vec!["7.x".into(), "7.1".into()]),
272 ("symfony/all-ok".into(), vec!["7.1".into()]),
273 ],
274 next: "7.2".into(),
275 };
276 let pruned = v.pruned(&cons("7.1.*"));
277 assert_eq!(
280 pruned.splits,
281 vec![
282 ("symfony/console".to_string(), vec!["7.1".to_string()]),
283 ("symfony/next".to_string(), vec!["7.1".to_string()]),
284 ]
285 );
286 }
287
288 #[test]
289 fn legacy_packages_are_removed_and_the_notice_is_due() {
290 let arena = vec![
291 pkg("symfony/console", "v7.0.3"),
292 pkg("symfony/console", "v7.1.0"),
293 pkg("symfony/not-split", "v1.0.0"),
294 pkg("symfony/locked", "v6.4.0"),
295 pkg("symfony/symfony", "v7.0.0"),
296 pkg("symfony/psr-http-message-bridge", "v2.3.1"),
297 pkg("symfony/all-in-range", "v6.0.0"),
298 ];
299 let packages: Vec<usize> = (0..arena.len()).collect();
300 let versions = FlexVersions {
301 splits: vec![
302 (
303 "symfony/console".into(),
304 vec!["6.4".into(), "7.0".into(), "7.1".into()],
305 ),
306 ("symfony/locked".into(), vec!["6.4".into(), "7.1".into()]),
307 (
308 "symfony/psr-http-message-bridge".into(),
309 vec!["6.4".into(), "7.1".into()],
310 ),
311 ("symfony/all-in-range".into(), vec!["7.1".into()]),
313 ],
314 next: "7.2".into(),
315 };
316 let mut locked = BTreeMap::new();
317 locked.insert("symfony/locked".to_string(), vec!["6.4.0.0".to_string()]);
318 let (kept, notice) = remove_legacy_packages(
319 &packages,
320 &arena,
321 &BTreeMap::new(),
322 &locked,
323 &cons("7.1.*"),
324 &versions,
325 );
326 assert_eq!(kept, vec![1, 2, 3, 5, 6, 4]);
331 assert!(notice);
332 }
333
334 #[test]
335 fn root_constraint_disjoint_from_symfony_require_keeps_the_package() {
336 let arena = vec![pkg("symfony/console", "v6.4.0")];
337 let versions = FlexVersions {
338 splits: vec![("symfony/console".into(), vec!["6.4".into(), "7.1".into()])],
339 next: "7.2".into(),
340 };
341 let mut root = BTreeMap::new();
342 root.insert("symfony/console".to_string(), cons("^6.4"));
343 let (kept, notice) = remove_legacy_packages(
344 &[0],
345 &arena,
346 &root,
347 &BTreeMap::new(),
348 &cons("7.1.*"),
349 &versions,
350 );
351 assert_eq!(kept, vec![0]);
352 assert!(!notice);
353 }
354
355 #[test]
356 fn merge_endpoints_first_wins() {
357 let a = json!({"versions": {"next": "7.2", "splits": {"x": ["7.1"]}}});
358 let b = json!({"versions": {"next": "9.9", "lts": "6.4"}});
359 let m = FlexVersions::merge_endpoints(&[a, b]);
360 assert_eq!(m["next"], "7.2");
361 assert_eq!(m["lts"], "6.4");
362 assert!(FlexVersions::from_value(&m).is_ok());
363 assert!(FlexVersions::from_value(&json!({"next": "7.2"})).is_err());
364 }
365
366 #[test]
367 fn php_float_cast() {
368 assert_eq!(php_float("7.1.0.0"), 7.1);
369 assert_eq!(php_float("2.3.1.0"), 2.3);
370 assert_eq!(php_float("dev-main"), 0.0);
371 assert_eq!(php_float("9999999-dev"), 9999999.0);
372 }
373}