Skip to main content

vivacity_resolver/
session.rs

1//! Setup of an `update`: what `Factory::createComposer` then
2//! `Installer::doUpdate` do before `createPool`: root, platform,
3//! repositories (global config + composer.json, same merge rules as
4//! `Config::merge`), lock repository, `Request`. Rust-side replica of
5//! tools/oracle-pool.php.
6
7use crate::constraint::{Constraint, Op};
8use crate::lockfile::{dump_package, lock_data, lock_packages, LockInput};
9use crate::optimizer::PoolOptimizer;
10use crate::package::{Origin, Package};
11use crate::platform::{platform_packages, probe};
12use crate::platform_filter::PlatformRequirementFilter;
13use crate::policy::DefaultPolicy;
14use crate::pool::{OrderedMap, Pool, PoolError, Repository, RepositorySet, Request};
15use crate::repository::{
16    locked_repository, ComposerRepository, FileTransport, HttpTransport, HttpTransports,
17};
18use crate::root::RootPackage;
19use crate::solver::{SolveError, Solver};
20use crate::transaction::LockTransaction;
21use crate::version::{parse_stability, regex, stability_rank};
22use pcre2::bytes::Regex;
23use serde_json::{Map, Value};
24use std::collections::{BTreeMap, BTreeSet};
25use std::path::Path;
26use std::sync::OnceLock;
27
28#[derive(Debug, thiserror::Error)]
29#[error("{message}")]
30pub struct SessionError {
31    pub message: String,
32    pub kind: SessionErrorKind,
33    /// A line Composer writes on STDOUT next to the error (`$io->write`
34    /// after `$io->writeError`), as `UpdateCommand` does for a temporary
35    /// constraint that misses the root's.
36    pub stdout: Option<String>,
37}
38
39/// What Composer does with the error: an unsolvable set
40/// (`SolverProblemsException`) means exit code 2 from `Installer::run`,
41/// everything else is an exception that propagates.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum SessionErrorKind {
44    Other,
45    Unsolvable,
46    /// Composer would carry this as a `TransportException`, whose code it
47    /// rewrites to 100 (`Application.php:502-506`, `Installer.php:92`).
48    Transport,
49    /// The command prints the message itself and returns a code, with no
50    /// `Error:` prefix and nothing written: `UpdateCommand`'s temporary
51    /// constraint refusal (1), `Installer::run`'s partial update without a
52    /// lock (3).
53    Printed(i32),
54}
55
56impl SessionError {
57    pub fn new(message: impl Into<String>) -> Self {
58        Self {
59            message: message.into(),
60            kind: SessionErrorKind::Other,
61            stdout: None,
62        }
63    }
64}
65
66impl From<PoolError> for SessionError {
67    fn from(e: PoolError) -> SessionError {
68        let transport = e.is_transport();
69        SessionError {
70            message: e.0,
71            kind: if transport {
72                SessionErrorKind::Transport
73            } else {
74                SessionErrorKind::Other
75            },
76            stdout: None,
77        }
78    }
79}
80
81/// The newtype errors of the other layers: a message, and nothing to
82/// distinguish — none of them is a transport failure.
83macro_rules! session_error_from {
84    ($($ty:path),+ $(,)?) => {
85        $(impl From<$ty> for SessionError {
86            fn from(e: $ty) -> SessionError {
87                SessionError::new(e.0)
88            }
89        })+
90    };
91}
92
93session_error_from!(
94    crate::loader::LoadError,
95    crate::platform::PlatformError,
96    crate::policy_config::PolicyError,
97    crate::root::RootError,
98);
99
100/// A filter error, on the other hand, can be either.
101impl From<crate::pool_filters::FilterError> for SessionError {
102    fn from(e: crate::pool_filters::FilterError) -> SessionError {
103        SessionError {
104            kind: if e.1 == crate::repository::RepoErrorKind::Transport {
105                SessionErrorKind::Transport
106            } else {
107                SessionErrorKind::Other
108            },
109            message: e.0,
110            stdout: None,
111        }
112    }
113}
114
115/// A repository error keeps its nature: Composer rewrites a
116/// `TransportException`'s code to `Installer::ERROR_TRANSPORT_EXCEPTION`
117/// (100) before Symfony turns it into an exit code, so a failure to FETCH
118/// something does not exit like a failure to understand it.
119impl From<crate::repository::RepoError> for SessionError {
120    fn from(e: crate::repository::RepoError) -> SessionError {
121        let transport = e.is_transport();
122        SessionError {
123            message: e.0,
124            kind: if transport {
125                SessionErrorKind::Transport
126            } else {
127                SessionErrorKind::Other
128            },
129            stdout: None,
130        }
131    }
132}
133
134/// `Config::$repositories` after merging: (name or index, definition).
135#[derive(Debug, Clone, PartialEq)]
136pub struct RepoConfig {
137    pub key: RepoKey,
138    pub definition: Value,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum RepoKey {
143    Named(String),
144    Indexed(u64),
145}
146
147/// `Config::merge` for the `repositories` key, applied in order (defaults,
148/// global config, composer.json).
149pub fn merge_repositories(current: &mut Vec<RepoConfig>, new: &Value) {
150    static PACKAGIST: OnceLock<Regex> = OnceLock::new();
151    let entries: Vec<(RepoKey, Value)> = match new {
152        Value::Array(list) => list
153            .iter()
154            .enumerate()
155            .map(|(i, v)| (RepoKey::Indexed(i as u64), v.clone()))
156            .collect(),
157        Value::Object(map) => map
158            .iter()
159            .map(|(k, v)| {
160                let key = match k.parse::<u64>() {
161                    Ok(i) if i.to_string() == *k => RepoKey::Indexed(i),
162                    _ => RepoKey::Named(k.clone()),
163                };
164                (key, v.clone())
165            })
166            .collect(),
167        _ => return,
168    };
169    if entries.is_empty() {
170        return;
171    }
172    current.reverse();
173    // `disableRepoByName((string) $name)`: a numeric string falls back to
174    // the integer key of the PHP array.
175    let disable = |current: &mut Vec<RepoConfig>, name: &str| {
176        let key = match name.parse::<u64>() {
177            Ok(i) if i.to_string() == name => RepoKey::Indexed(i),
178            _ => RepoKey::Named(name.to_owned()),
179        };
180        if current.iter().any(|r| r.key == key) {
181            current.retain(|r| r.key != key);
182        } else if name == "packagist" {
183            current.retain(|r| r.key != RepoKey::Named("packagist.org".into()));
184        }
185    };
186    for (key, repository) in entries.into_iter().rev() {
187        if repository == Value::Bool(false) {
188            let name = match &key {
189                RepoKey::Named(n) => n.clone(),
190                RepoKey::Indexed(i) => i.to_string(),
191            };
192            disable(current, &name);
193            continue;
194        }
195        if let Some(obj) = repository.as_object() {
196            if obj.len() == 1 && obj.values().next() == Some(&Value::Bool(false)) {
197                disable(current, obj.keys().next().map(String::as_str).unwrap_or(""));
198                continue;
199            }
200        }
201        if repository.get("type").and_then(Value::as_str) == Some("composer") {
202            if let Some(url) = repository.get("url").and_then(Value::as_str) {
203                let re = regex(
204                    &PACKAGIST,
205                    r"^https?://(?:[a-z0-9-.]+\.)?packagist.org(/|$)",
206                    false,
207                );
208                if re.is_match(url.as_bytes()).unwrap_or(false) {
209                    disable(current, "packagist.org");
210                }
211            }
212        }
213        match key {
214            RepoKey::Indexed(i) => {
215                if current.iter().any(|r| r.key == RepoKey::Indexed(i)) {
216                    let next = current
217                        .iter()
218                        .filter_map(|r| match r.key {
219                            RepoKey::Indexed(j) => Some(j + 1),
220                            _ => None,
221                        })
222                        .max()
223                        .unwrap_or(0);
224                    current.push(RepoConfig {
225                        key: RepoKey::Indexed(next),
226                        definition: repository,
227                    });
228                } else {
229                    current.push(RepoConfig {
230                        key: RepoKey::Indexed(i),
231                        definition: repository,
232                    });
233                }
234            }
235            RepoKey::Named(name) => {
236                let name = if name == "packagist" {
237                    "packagist.org".to_owned()
238                } else {
239                    name
240                };
241                let key = RepoKey::Named(name);
242                if let Some(slot) = current.iter_mut().find(|r| r.key == key) {
243                    slot.definition = repository;
244                } else {
245                    current.push(RepoConfig {
246                        key,
247                        definition: repository,
248                    });
249                }
250            }
251        }
252    }
253    current.reverse();
254}
255
256/// Merged configuration relevant to the resolver.
257#[derive(Debug, Clone, Default)]
258pub struct MergedConfig {
259    pub repositories: Vec<RepoConfig>,
260    /// `config.platform` (the last definition replaces the previous one).
261    pub platform: Map<String, Value>,
262    /// `config.policy` and `config.audit` merged (`Config::merge`).
263    pub policy: crate::policy_config::RawPolicyConfig,
264}
265
266impl MergedConfig {
267    /// Defaults + `COMPOSER_HOME/config.json` + composer.json, like
268    /// `Factory::createConfig` then `Config::merge($localConfig)`.
269    pub fn load(
270        manifest: &Value,
271        composer_home: Option<&Path>,
272    ) -> Result<MergedConfig, SessionError> {
273        let mut cfg = MergedConfig {
274            repositories: vec![RepoConfig {
275                key: RepoKey::Named("packagist.org".into()),
276                definition: serde_json::json!({"type": "composer", "url": "https://repo.packagist.org"}),
277            }],
278            platform: Map::new(),
279            policy: crate::policy_config::RawPolicyConfig::default(),
280        };
281        if let Some(home) = composer_home {
282            let global = home.join("config.json");
283            if global.is_file() {
284                let text = std::fs::read_to_string(&global)
285                    .map_err(|e| SessionError::new(format!("{}: {e}", global.display())))?;
286                let v: Value = serde_json::from_str(&text)
287                    .map_err(|e| SessionError::new(format!("{}: {e}", global.display())))?;
288                cfg.merge(&v);
289            }
290        }
291        cfg.merge(manifest);
292        Ok(cfg)
293    }
294
295    fn merge(&mut self, config: &Value) {
296        // `$this->config['platform'] = $val`: any value replaces the
297        // previous one (`[]` or `null` clear it).
298        if let Some(platform) = config.get("config").and_then(|c| c.get("platform")) {
299            self.platform = platform.as_object().cloned().unwrap_or_default();
300        }
301        if let Some(repos) = config.get("repositories") {
302            merge_repositories(&mut self.repositories, repos);
303        }
304        if let Some(cfg) = config.get("config").and_then(Value::as_object) {
305            self.policy.merge(cfg);
306        }
307    }
308}
309
310/// Result of a `solve`: the transaction and what is needed to compare it
311/// with Composer.
312pub struct SolveReport {
313    pub transaction: LockTransaction,
314    /// Decided literals, in order.
315    pub decisions: Vec<i64>,
316    /// `getRuleSetSize()`.
317    pub rules: usize,
318    /// Learned rules (conflicts encountered).
319    pub learned: usize,
320}
321
322/// Options of an `update` (`Installer::setUpdateAllowList`,
323/// `setUpdateAllowTransitiveDependencies`).
324#[derive(Debug, Clone, Default)]
325pub struct UpdateOptions {
326    /// `composer update a/b c/*`: patterns, lowercased and deduplicated.
327    pub allow_list: Vec<String>,
328    pub transitive: Option<crate::pool::UpdateMode>,
329    /// `--no-blocking` / `--no-security-blocking`.
330    pub no_blocking: bool,
331    /// Dry run of `require`/`remove`: the root package is patched in
332    /// memory, composer.json is not touched.
333    pub root_patch: Option<crate::root::RootPatch>,
334    /// composer-merge-plugin active: the root as its INIT /
335    /// `PRE_UPDATE_CMD` merges left it (the merged manifest, the
336    /// included files' own requirements for the stability flags).
337    pub merged: Option<crate::merge_plugin::Merged>,
338    /// `update --with a/b:^1` (and the `update a/b:^1` shorthand), as
339    /// written: name → constraint text, in command order. Parsed and
340    /// checked against the root requirements by `prepare_update`.
341    pub temporary_requirements: Vec<(String, String)>,
342}
343
344impl UpdateOptions {
345    /// `Installer::setUpdateAllowList`: `strtolower` + `array_unique`.
346    pub fn partial(packages: &[String], transitive: crate::pool::UpdateMode) -> UpdateOptions {
347        let mut allow_list: Vec<String> = Vec::new();
348        for p in packages {
349            let l = p.to_lowercase();
350            if !allow_list.contains(&l) {
351                allow_list.push(l);
352            }
353        }
354        UpdateOptions {
355            allow_list,
356            transitive: Some(transitive),
357            ..Default::default()
358        }
359    }
360}
361
362/// `UpdateCommand::execute`'s temporary-constraint block: the root's
363/// references and stability flags take the requirements in, a name with
364/// `*` expands over the root requirements, and every constraint must
365/// intersect the one in composer.json — otherwise the command fails with
366/// its own message (exit 1), before anything is written.
367fn temporary_constraints(
368    reqs: &[(String, String)],
369    root: &mut RootPackage,
370) -> Result<BTreeMap<String, crate::pool::TemporaryConstraint>, SessionError> {
371    if reqs.is_empty() {
372        return Ok(BTreeMap::new());
373    }
374    let pairs: Vec<(String, String)> = reqs.to_vec();
375    crate::root::extract_references(&pairs, &mut root.references);
376    let minimum = root.minimum_stability.clone();
377    crate::root::extract_stability_flags(&pairs, &minimum, &mut root.stability_flags);
378    // `$rootRequirements = array_merge(getRequires(), getDevRequires())`:
379    // composer.json's order, which is the order a wildcard reports its
380    // first mismatch in.
381    let root_requires: Vec<(String, String, Constraint)> = root
382        .all_requires()
383        .iter()
384        .map(|l| {
385            (
386                l.target.clone(),
387                l.pretty_constraint.clone(),
388                l.constraint.clone(),
389            )
390        })
391        .collect();
392    let fail = |message: String, stdout: Option<String>| SessionError {
393        message,
394        kind: SessionErrorKind::Printed(1),
395        stdout,
396    };
397    let mut out: BTreeMap<String, crate::pool::TemporaryConstraint> = BTreeMap::new();
398    for (name, text) in &pairs {
399        let name = name.to_lowercase();
400        let parsed = crate::pool::TemporaryConstraint {
401            pretty: text.clone(),
402            constraint: crate::constraint::parse_constraints(text)
403                .map_err(|e| SessionError::new(format!("{name}: {e}")))?
404                .constraint,
405        };
406        if name.contains('*') {
407            let re = crate::pool::package_name_regexp(&name);
408            for (target, pretty, constraint) in &root_requires {
409                if !re.is_match(target.as_bytes()).unwrap_or(false) {
410                    continue;
411                }
412                out.insert(target.clone(), parsed.clone());
413                if !crate::intervals::have_intersections(&parsed.constraint, constraint) {
414                    return Err(fail(
415                        format!(
416                            "The temporary constraint \"{text}\" for \"{name}\" matching \"{target}\" must be a subset of the constraint in your composer.json ({pretty})"
417                        ),
418                        None,
419                    ));
420                }
421            }
422        } else {
423            out.insert(name.clone(), parsed.clone());
424            if let Some((_, pretty, constraint)) = root_requires.iter().find(|(t, _, _)| *t == name)
425            {
426                if !crate::intervals::have_intersections(&parsed.constraint, constraint) {
427                    return Err(fail(
428                        format!(
429                            "The temporary constraint \"{text}\" for \"{name}\" must be a subset of the constraint in your composer.json ({pretty})"
430                        ),
431                        Some(format!(
432                            "Run `composer require {name}` or `composer require {name}:{text}` instead to replace the constraint"
433                        )),
434                    ));
435                }
436            }
437        }
438    }
439    Ok(out)
440}
441
442/// Everything `Installer::doUpdate` has at hand right before `createPool`.
443/// symfony/flex active for this resolution: its requirement and index.
444#[derive(Debug, Clone)]
445pub struct FlexSession {
446    pub symfony_require: String,
447    pub symfony: Constraint,
448    pub versions: crate::flex_filter::FlexVersions,
449}
450
451pub struct UpdateSession {
452    /// `None`: no Flex filter at `PRE_POOL_CREATE`.
453    pub flex: Option<FlexSession>,
454    pub arena: Vec<Package>,
455    pub root: RootPackage,
456    /// Arena indices of the fixed root (requires emptied) and of its alias.
457    pub fixed_root: usize,
458    pub fixed_root_alias: Option<usize>,
459    pub platform: Vec<usize>,
460    pub locked: Option<Vec<usize>>,
461    pub set: RepositorySet,
462    pub request: Request,
463    pub config: MergedConfig,
464    pub dev_mode: bool,
465    /// `--prefer-stable` / `--prefer-lowest` from the command line.
466    pub prefer_stable: bool,
467    pub prefer_lowest: bool,
468    /// `PHP_MAJOR.MINOR.RELEASE` of the probed PHP (`ext-*` rule of the
469    /// `VersionSelector`).
470    pub php_version: String,
471    /// The pool blocking policies (`createPolicyConfig`).
472    pub policy_config: crate::policy_config::PolicyConfig,
473    /// `XdebugHandler::getAllIniFiles()` of the probed PHP (extension hint
474    /// of an unsolvable set).
475    pub ini_files: Vec<String>,
476    /// `ext-*` loaded by the probed PHP, before `config.platform`.
477    pub loaded_extensions: BTreeSet<String>,
478    /// `Installer::$devMode` (`--no-dev` / `--update-no-dev` clear it):
479    /// only the `--no-dev` warning of an unsolvable set reads it.
480    pub installer_dev_mode: bool,
481}
482
483impl UpdateSession {
484    pub fn prepare(
485        project_dir: &Path,
486        composer_home: Option<&Path>,
487        dev_mode: bool,
488    ) -> Result<UpdateSession, SessionError> {
489        Self::prepare_with(project_dir, composer_home, dev_mode, None)
490    }
491
492    /// Like `prepare`, with a network transport for `https://` repositories.
493    pub fn prepare_with(
494        project_dir: &Path,
495        composer_home: Option<&Path>,
496        dev_mode: bool,
497        http: Option<HttpTransports>,
498    ) -> Result<UpdateSession, SessionError> {
499        Self::prepare_full(project_dir, composer_home, dev_mode, http, None)
500    }
501
502    /// Like `prepare_with`, with Composer's `cache-repo-dir` directory for
503    /// the metadata cache (read and written in Composer's format).
504    pub fn prepare_full(
505        project_dir: &Path,
506        composer_home: Option<&Path>,
507        dev_mode: bool,
508        http: Option<HttpTransports>,
509        cache_repo_dir: Option<&Path>,
510    ) -> Result<UpdateSession, SessionError> {
511        Self::prepare_update(
512            project_dir,
513            composer_home,
514            dev_mode,
515            http,
516            cache_repo_dir,
517            &UpdateOptions::default(),
518        )
519    }
520
521    /// Like `prepare_full`, with the allow list of a partial update
522    /// (`composer update a/b`).
523    pub fn prepare_update(
524        project_dir: &Path,
525        composer_home: Option<&Path>,
526        dev_mode: bool,
527        http: Option<HttpTransports>,
528        cache_repo_dir: Option<&Path>,
529        options: &UpdateOptions,
530    ) -> Result<UpdateSession, SessionError> {
531        let partial_update = !options.allow_list.is_empty();
532        let manifest_path = project_dir.join("composer.json");
533        let manifest_text = std::fs::read_to_string(&manifest_path)
534            .map_err(|e| SessionError::new(format!("{}: {e}", manifest_path.display())))?;
535        let manifest: Value = serde_json::from_str(&manifest_text)
536            .map_err(|e| SessionError::new(format!("{}: {e}", manifest_path.display())))?;
537        let config = MergedConfig::load(&manifest, composer_home)?;
538        let mut policy_config = crate::policy_config::PolicyConfig::from_raw(&config.policy)
539            .map_err(SessionError::from)?;
540        policy_config
541            .apply_no_blocking(options.no_blocking)
542            .map_err(SessionError::from)?;
543        let mut root = RootPackage::load(&manifest, project_dir).map_err(SessionError::from)?;
544        if let Some(merged) = &options.merged {
545            // `RootPackageLoader` ran on the file: its stability flags are
546            // the starting point. The plugin then merges each file's
547            // links, aliases and references into the root; the flags of a
548            // merged file come from its own `require` (and `require-dev`
549            // in dev mode), `mergeStabilityFlags` — not from the merged
550            // constraint text, which the loader never sees.
551            let flags = std::mem::take(&mut root.stability_flags);
552            root = RootPackage::load(&merged.manifest, project_dir).map_err(SessionError::from)?;
553            root.stability_flags = flags;
554            // (`require_dev` is recorded only when the merge ran in dev
555            // mode — `mergeDevInto`, a second pass over the files.)
556            for include in &merged.include_links {
557                crate::root::merge_plugin_stability_flags(
558                    &mut root.stability_flags,
559                    &root.minimum_stability,
560                    &include.require,
561                );
562            }
563            for include in &merged.include_links {
564                crate::root::merge_plugin_stability_flags(
565                    &mut root.stability_flags,
566                    &root.minimum_stability,
567                    &include.require_dev,
568                );
569            }
570        }
571        if let Some(patch) = &options.root_patch {
572            root.apply_patch(patch).map_err(SessionError::from)?;
573        }
574        // `UpdateCommand`: the temporary requirements feed the root's
575        // references and stability flags before anything else reads them,
576        // then become the pool's temporary constraints.
577        let temporary_constraints =
578            temporary_constraints(&options.temporary_requirements, &mut root)?;
579        let probed = probe().map_err(SessionError::from)?;
580        // `PHP_MAJOR_VERSION.PHP_MINOR_VERSION.PHP_RELEASE_VERSION` of the
581        // actual PHP (no `config.platform.php` here): the first three
582        // numbers of PHP_VERSION.
583        let php_version = probed
584            .iter()
585            .find(|p| p.get("name").and_then(Value::as_str) == Some("php"))
586            .and_then(|p| p.get("version").and_then(Value::as_str))
587            .map(|v| {
588                v.split(['.', '-', '+'])
589                    .take(3)
590                    .map(|part| part.trim_end_matches(|c: char| !c.is_ascii_digit()))
591                    .collect::<Vec<_>>()
592                    .join(".")
593            })
594            .unwrap_or_default();
595        let platform_pkgs =
596            platform_packages(&probed, &config.platform).map_err(SessionError::from)?;
597
598        let mut arena: Vec<Package> = Vec::new();
599
600        // `$fixedRootPackage = clone $package; setRequires([]); setDevRequires([])`.
601        let mut fixed = root.package.clone();
602        fixed.requires = Default::default();
603        fixed.dev_requires = Default::default();
604        arena.push(fixed);
605        let fixed_root = 0;
606        let fixed_root_alias = root.branch_alias.as_ref().map(|(normalized, pretty)| {
607            let a = arena[fixed_root].alias(fixed_root, normalized, pretty);
608            arena.push(a);
609            arena.len() - 1
610        });
611        let root_members: Vec<usize> = fixed_root_alias
612            .into_iter()
613            .chain(std::iter::once(fixed_root))
614            .collect();
615
616        let mut platform: Vec<usize> = Vec::new();
617        for p in platform_pkgs {
618            arena.push(p);
619            platform.push(arena.len() - 1);
620        }
621
622        // `Locker::isLocked()` = file present and `isset($data['packages'])`;
623        // an unreadable lock is ignored for a full update (`doUpdate`
624        // swallows the ParsingException), fatal for a partial one.
625        let lock_path = project_dir.join("composer.lock");
626        let lock: Option<Value> = if lock_path.is_file() {
627            let text = std::fs::read_to_string(&lock_path)
628                .map_err(|e| SessionError::new(format!("{}: {e}", lock_path.display())))?;
629            match serde_json::from_str::<Value>(&text) {
630                Ok(v) if v.get("packages").is_some_and(|p| !p.is_null()) => Some(v),
631                Ok(_) => None,
632                Err(e) => {
633                    if partial_update {
634                        return Err(SessionError::new(format!(
635                            "\"{}\" does not contain valid JSON\n{e}",
636                            lock_path.display()
637                        )));
638                    }
639                    None
640                }
641            }
642        } else {
643            None
644        };
645        let locked = match &lock {
646            Some(v) => Some(locked_repository(v, &mut arena).map_err(SessionError::from)?),
647            None => None,
648        };
649
650        // `$stabilityFlags[$package->getName()] = STABILITIES[parseStability($package->getVersion())]`:
651        // the version seen is that of the root alias if there is one.
652        let mut stability_flags = root.stability_flags.clone();
653        let root_version = match fixed_root_alias {
654            Some(a) => arena[a].version.clone(),
655            None => arena[fixed_root].version.clone(),
656        };
657        stability_flags.insert(
658            root.package.name.clone(),
659            stability_rank(parse_stability(&root_version)),
660        );
661
662        // `createRepositorySet(forUpdate)` and `requirePackagesForUpdate(..., true)`
663        // always take require + require-dev, `--no-dev` or not; and with a
664        // root alias, `$this->package` is the RootAliasPackage, whose
665        // `self.version` links target the alias version.
666        let mut root_requires: OrderedMap<Constraint> = OrderedMap::default();
667        let requires = match &root.branch_alias {
668            Some((normalized, pretty)) => {
669                let aliased = root.package.alias(0, normalized, pretty);
670                let mut all = aliased.requires.clone();
671                for l in aliased.dev_requires.iter() {
672                    all.insert(l.clone());
673                }
674                all
675            }
676            None => root.all_requires(),
677        };
678        for link in requires.iter() {
679            root_requires.insert(&link.target, link.constraint.clone());
680        }
681
682        let mut set = RepositorySet::new(
683            &root.minimum_stability,
684            stability_flags,
685            &root.aliases,
686            root.references.clone(),
687            root_requires,
688            temporary_constraints,
689        );
690        set.add_repository(Repository::Root(root_members));
691        set.add_repository(Repository::Platform(platform.clone()));
692        for repo in &config.repositories {
693            let origin = Origin::Repository(set.repositories.len());
694            set.add_repository(open_repository(
695                repo,
696                http.as_ref(),
697                cache_repo_dir,
698                project_dir,
699                origin,
700                &mut arena,
701            )?);
702        }
703        if let Some(ids) = &locked {
704            set.add_repository(Repository::Locked(ids.clone()));
705        }
706
707        // `Installer::run`: a partial update requires a lock — the message
708        // alone on stderr, exit 3.
709        if partial_update && locked.is_none() {
710            return Err(SessionError {
711                message: "Cannot update only a partial set of packages without a lock file present. Run `composer update` to generate a lock file.".to_owned(),
712                kind: SessionErrorKind::Printed(3),
713                stdout: None,
714            });
715        }
716        let mut request = Request::new(locked.clone());
717        if partial_update {
718            request.set_update_allow_list(
719                options.allow_list.clone(),
720                options
721                    .transitive
722                    .unwrap_or(crate::pool::UpdateMode::OnlyListed),
723            );
724        }
725        if let Some(a) = fixed_root_alias {
726            request.fix_package(a);
727        }
728        request.fix_package(fixed_root);
729        for &p in &platform {
730            let provided = arena[fixed_root]
731                .provides
732                .get(&arena[p].name)
733                .map(|l| l.constraint.clone());
734            let provided_here = provided
735                .is_some_and(|c| c.matches(&Constraint::new(Op::Eq, arena[p].version.clone())));
736            if !provided_here {
737                request.fix_package(p);
738            }
739        }
740        for link in requires.iter() {
741            request.require_name_pretty(
742                &link.target,
743                Some(link.constraint.clone()),
744                &link.pretty_constraint,
745            )?;
746        }
747
748        Ok(UpdateSession {
749            flex: None,
750            arena,
751            root,
752            fixed_root,
753            fixed_root_alias,
754            platform,
755            locked,
756            set,
757            request,
758            config,
759            dev_mode,
760            prefer_stable: false,
761            prefer_lowest: false,
762            php_version,
763            policy_config,
764            ini_files: crate::platform::ini_files(&probed),
765            loaded_extensions: crate::platform::loaded_extensions(&probed),
766            installer_dev_mode: true,
767        })
768    }
769
770    pub fn create_pool(&mut self) -> Result<Pool, SessionError> {
771        let pre_pool = self.flex.as_ref().map(|f| {
772            // `$rootPackage->getRequires() + $rootPackage->getDevRequires()`:
773            // a name in both keeps the `require` constraint.
774            let mut root_constraints = BTreeMap::new();
775            for link in self
776                .root
777                .package
778                .requires
779                .0
780                .iter()
781                .chain(self.root.package.dev_requires.0.iter())
782            {
783                root_constraints
784                    .entry(link.target.clone())
785                    .or_insert_with(|| link.constraint.clone());
786            }
787            crate::pool::PrePoolFilter {
788                symfony_require: f.symfony_require.clone(),
789                symfony: f.symfony.clone(),
790                root_constraints,
791                versions: f.versions.clone(),
792            }
793        });
794        Ok(self
795            .set
796            .create_pool(&mut self.request, &mut self.arena, pre_pool.as_ref())?)
797    }
798
799    /// `Installer::createPolicy(true, ...)` without `--minimal-changes`.
800    /// With Flex active, `COMPOSER_PREFER_DEV_OVER_PRERELEASE` is what Flex
801    /// sets before Composer builds the policy.
802    pub fn policy(&self) -> DefaultPolicy {
803        let mut policy = DefaultPolicy::new(
804            self.prefer_stable || self.root.prefer_stable,
805            self.prefer_lowest,
806            None,
807        );
808        if self.flex.is_some() {
809            policy.prefer_dev_over_prerelease = true;
810        }
811        policy
812    }
813
814    /// `Installer::extractDevPackages`: second solve without the
815    /// require-dev, on only the packages kept by the first, to classify
816    /// `packages` / `packages-dev`.
817    pub fn extract_dev_packages(
818        &mut self,
819        transaction: &mut LockTransaction,
820        policy: &mut DefaultPolicy,
821        filter: &PlatformRequirementFilter,
822    ) -> Result<(), SessionError> {
823        if self.root.package.dev_requires.is_empty() {
824            return Ok(());
825        }
826        // `$resultRepo`: each package reloaded from its dump (`load`, one at
827        // a time), branch aliases recreated -> [alias, base].
828        let dumps: Vec<Value> = transaction
829            .new_lock_packages(&self.arena, false)
830            .iter()
831            .map(|&idx| Value::Object(dump_package(&self.arena[idx])))
832            .collect();
833        let result_ids =
834            crate::loader::load_packages(&dumps, Origin::Result, &mut self.arena, false)
835                .map_err(SessionError::from)?;
836        // createPoolWithAllPackages: root, platform, result, with the root
837        // aliases applied along the way.
838        let mut members: Vec<usize> = Vec::new();
839        members.extend(self.fixed_root_alias);
840        members.push(self.fixed_root);
841        members.extend(self.platform.iter().copied());
842        members.extend(result_ids);
843        let mut pool_packages: Vec<usize> = Vec::new();
844        for idx in members {
845            pool_packages.push(idx);
846            let (name, version) = (
847                self.arena[idx].name.clone(),
848                self.arena[idx].version.clone(),
849            );
850            if let Some((alias, alias_normalized)) = self
851                .set
852                .root_aliases
853                .get(&name)
854                .and_then(|m| m.get(&version))
855            {
856                let mut base = idx;
857                while let Some(b) = self.arena[base].alias_of {
858                    base = b;
859                }
860                let mut a = self.arena[base].alias(base, alias_normalized, alias);
861                a.root_package_alias = true;
862                a.origin = Origin::Detached;
863                self.arena.push(a);
864                pool_packages.push(self.arena.len() - 1);
865            }
866        }
867        let pool = Pool::new(pool_packages, Vec::new(), &self.arena);
868        // createRequest (without lock) + requirePackagesForUpdate(..., false).
869        let mut request = Request::new(None);
870        if let Some(a) = self.fixed_root_alias {
871            request.fix_package(a);
872        }
873        request.fix_package(self.fixed_root);
874        for &p in &self.platform {
875            let provided = self.arena[self.fixed_root]
876                .provides
877                .get(&self.arena[p].name)
878                .map(|l| l.constraint.clone());
879            let provided_here = provided.is_some_and(|c| {
880                c.matches(&Constraint::new(Op::Eq, self.arena[p].version.clone()))
881            });
882            if !provided_here {
883                request.fix_package(p);
884            }
885        }
886        let requires = match &self.root.branch_alias {
887            Some((normalized, pretty)) => self.root.package.alias(0, normalized, pretty).requires,
888            None => self.root.package.requires.clone(),
889        };
890        for link in requires.iter() {
891            request.require_name_pretty(
892                &link.target,
893                Some(link.constraint.clone()),
894                &link.pretty_constraint,
895            )?;
896        }
897        let mut solver = Solver::new(&pool, &self.arena);
898        let non_dev = match solver.solve(&request, policy, filter) {
899            Ok(t) => t,
900            Err(SolveError::Problems(problems)) => {
901                drop(solver);
902                // `Installer::extractDevPackages`: `$isDevExtraction`, on a
903                // request without lock built for this solve.
904                let pretty = {
905                    let mut ctx = crate::problem::MessageContext {
906                        arena: &mut self.arena,
907                        set: &self.set,
908                        pool: &pool,
909                        request: &request,
910                        is_verbose: false,
911                        ini_files: &self.ini_files,
912                        loaded_extensions: &self.loaded_extensions,
913                    };
914                    crate::problem::pretty_string(&mut ctx, &problems, true)
915                };
916                return Err(SessionError {
917                    message: format!("Unable to find a compatible set of packages based on your non-dev requirements alone.\nYour requirements can be resolved successfully when require-dev packages are present.\nYou may need to move packages from require-dev or some of their dependencies to require.\n{pretty}"),
918                    kind: SessionErrorKind::Unsolvable,
919                    stdout: None,
920                });
921            }
922            Err(SolveError::Bug(b)) => return Err(SessionError::new(b)),
923        };
924        transaction.set_non_dev_packages(&self.arena, &non_dev);
925        Ok(())
926    }
927
928    /// `extractPlatformRequirements($links)`.
929    fn platform_requirements(links: &crate::package::Links) -> Map<String, Value> {
930        let mut out = Map::new();
931        for l in links.iter() {
932            if crate::platform::is_platform_package(&l.target) {
933                out.insert(l.target.clone(), Value::String(l.pretty_constraint.clone()));
934            }
935        }
936        out
937    }
938
939    /// `Locker::setLockData(...)`: the lock JSON to write.
940    pub fn lock_json(
941        &self,
942        transaction: &LockTransaction,
943        manifest_text: &str,
944    ) -> Result<Value, SessionError> {
945        let content_hash = vivacity_core::content_hash::content_hash(manifest_text)
946            .map_err(|e| SessionError::new(e.to_string()))?;
947        let packages = lock_packages(
948            &self.arena,
949            &transaction.new_lock_packages(&self.arena, false),
950        )
951        .map_err(SessionError::new)?;
952        let packages_dev = lock_packages(
953            &self.arena,
954            &transaction.new_lock_packages(&self.arena, true),
955        )
956        .map_err(SessionError::new)?;
957        let (requires, dev_requires) = match &self.root.branch_alias {
958            Some((normalized, pretty)) => {
959                let a = self.root.package.alias(0, normalized, pretty);
960                (a.requires, a.dev_requires)
961            }
962            None => (
963                self.root.package.requires.clone(),
964                self.root.package.dev_requires.clone(),
965            ),
966        };
967        Ok(lock_data(LockInput {
968            content_hash: &content_hash,
969            packages,
970            packages_dev: Some(packages_dev),
971            platform: Self::platform_requirements(&requires),
972            platform_dev: Self::platform_requirements(&dev_requires),
973            aliases: &transaction.aliases(&self.arena, &self.root.aliases),
974            minimum_stability: &self.root.minimum_stability,
975            stability_flags: &self.root.stability_flags,
976            prefer_stable: self.prefer_stable || self.root.prefer_stable,
977            prefer_lowest: self.prefer_lowest,
978            platform_overrides: &self.config.platform,
979        }))
980    }
981
982    /// Full `composer update --no-install`: solve, dev package extraction,
983    /// lock data. Returns the lock JSON and the report of the first solve.
984    pub fn update(
985        &mut self,
986        manifest_text: &str,
987        filter: &PlatformRequirementFilter,
988    ) -> Result<(Value, SolveReport), SessionError> {
989        let trace = std::env::var_os("VIVACITY_TRACE").is_some();
990        let t = std::time::Instant::now();
991        let lap = |label: &str, t: &std::time::Instant| {
992            if trace {
993                eprintln!(
994                    "trace: {label:<22} {:>7.1} ms",
995                    t.elapsed().as_secs_f64() * 1000.0
996                );
997            }
998        };
999        let mut policy = self.policy();
1000        let pool = self.create_filtered_pool()?;
1001        // `Installer::doUpdate`: the pool is built (Flex's notice comes out
1002        // of it), then `Updating dependencies`, then the policy filters'
1003        // warnings as they happened.
1004        if let Some(notice) = &pool.flex_notice {
1005            eprintln!("{notice}");
1006        }
1007        eprintln!("Updating dependencies");
1008        for w in &pool.warnings {
1009            eprintln!("{w}");
1010        }
1011        lap("pool", &t);
1012        let pool = if std::env::var("COMPOSER_POOL_OPTIMIZER").as_deref() == Ok("0") {
1013            pool
1014        } else {
1015            PoolOptimizer::new().optimize(&self.request, &pool, &self.arena, &mut policy)
1016        };
1017        lap("optimize", &t);
1018        let mut report = match self.solve(&pool, &mut policy, filter) {
1019            Ok(r) => r,
1020            Err(SolveError::Problems(problems)) => {
1021                // `Installer::doUpdate`: the headline, the pretty problems,
1022                // the `--no-dev` warning.
1023                let pretty = self.pretty_problems(&pool, &problems, false);
1024                let mut message = format!(
1025                    "Your requirements could not be resolved to an installable set of packages.\n{pretty}"
1026                );
1027                if !self.installer_dev_mode {
1028                    message.push_str("\nRunning update with --no-dev does not mean require-dev is ignored, it just means the packages will not be installed. If dev requirements are blocking the update you have to resolve those problems.");
1029                }
1030                return Err(SessionError {
1031                    message,
1032                    kind: SessionErrorKind::Unsolvable,
1033                    stdout: None,
1034                });
1035            }
1036            Err(SolveError::Bug(b)) => return Err(SessionError::new(b)),
1037        };
1038        lap("solve", &t);
1039        // `ValidatingArrayLoader::validatePackage` on every kept package
1040        // (`LockTransaction::setResultPackages`): a SecurityException stops
1041        // the update.
1042        for &idx in &report.transaction.all {
1043            crate::lockfile::validate_package(&self.arena[idx]).map_err(SessionError::new)?;
1044        }
1045        drop(pool);
1046        let mut transaction = std::mem::replace(&mut report.transaction, LockTransaction::empty());
1047        self.extract_dev_packages(&mut transaction, &mut policy, filter)?;
1048        lap("extract dev", &t);
1049        let lock = self.lock_json(&transaction, manifest_text)?;
1050        lap("lock data", &t);
1051        report.transaction = transaction;
1052        Ok((lock, report))
1053    }
1054
1055    /// `SolverProblemsException::getPrettyString` for problems found on
1056    /// `pool` with this session's request.
1057    pub fn pretty_problems(
1058        &mut self,
1059        pool: &Pool,
1060        problems: &[crate::solver::SolvedProblem],
1061        is_dev_extraction: bool,
1062    ) -> String {
1063        let mut ctx = crate::problem::MessageContext {
1064            arena: &mut self.arena,
1065            set: &self.set,
1066            pool,
1067            request: &self.request,
1068            is_verbose: false,
1069            ini_files: &self.ini_files,
1070            loaded_extensions: &self.loaded_extensions,
1071        };
1072        crate::problem::pretty_string(&mut ctx, problems, is_dev_extraction)
1073    }
1074
1075    /// `createPool` with the PoolOptimizer (unless `COMPOSER_POOL_OPTIMIZER=0`),
1076    /// like `Installer::doUpdate`.
1077    /// `RepositorySet::findPackages($name)` on the `CompositeRepository` of
1078    /// `require` (platform then project repositories, all merged) with a
1079    /// `RepositorySet` reduced to `minimum-stability` (no flags);
1080    /// `ignore_stability` means `ALLOW_UNACCEPTABLE_STABILITIES`.
1081    pub fn find_packages_for_require(
1082        &mut self,
1083        name: &str,
1084        ignore_stability: bool,
1085    ) -> Result<Vec<usize>, SessionError> {
1086        let name = name.to_lowercase();
1087        let mut acceptable = BTreeMap::new();
1088        let min = crate::version::stability_rank(&self.root.minimum_stability);
1089        for st in ["stable", "RC", "beta", "alpha", "dev"] {
1090            let rank = crate::version::stability_rank(st);
1091            if ignore_stability || rank <= min {
1092                acceptable.insert(st.to_owned(), rank);
1093            }
1094        }
1095        let flags = BTreeMap::new();
1096        let already = BTreeMap::new();
1097        let map = vec![(name.clone(), Constraint::MatchAll)];
1098        let mut found: Vec<usize> = Vec::new();
1099        let (_, ids) = crate::pool::array_repository_load_packages(
1100            &self.platform,
1101            &map,
1102            &acceptable,
1103            &flags,
1104            &already,
1105            &self.arena,
1106        );
1107        found.extend(ids);
1108        for (i, repo) in self.set.repositories.iter().enumerate() {
1109            if let Repository::Composer(repo) = repo {
1110                let (_, ids) = repo
1111                    .load_packages(
1112                        &map,
1113                        &acceptable,
1114                        &flags,
1115                        &already,
1116                        Origin::Repository(i),
1117                        &mut self.arena,
1118                    )
1119                    .map_err(SessionError::from)?;
1120                found.extend(ids);
1121            }
1122        }
1123        Ok(found)
1124    }
1125
1126    pub fn create_optimized_pool(
1127        &mut self,
1128        policy: &mut DefaultPolicy,
1129    ) -> Result<Pool, SessionError> {
1130        let pool = self.create_filtered_pool()?;
1131        if std::env::var("COMPOSER_POOL_OPTIMIZER").as_deref() == Ok("0") {
1132            return Ok(pool);
1133        }
1134        Ok(PoolOptimizer::new().optimize(&self.request, &pool, &self.arena, policy))
1135    }
1136
1137    /// `buildPool` up to the policy filters (advisories, lists), without the
1138    /// optimizer; their warnings go into `pool.warnings`.
1139    pub fn create_filtered_pool(&mut self) -> Result<Pool, SessionError> {
1140        let mut pool = self.create_pool()?;
1141        let before = pool.len();
1142        // The policy filters rebuild the pool: what PRE_POOL_CREATE noted
1143        // rides along.
1144        let flex_notice = pool.flex_notice.take();
1145        let mut warnings = Vec::new();
1146        pool = crate::pool_filters::security_advisory_filter(
1147            pool,
1148            &self.arena,
1149            &self.set.repositories,
1150            &self.request,
1151            &self.policy_config,
1152            &mut warnings,
1153        )
1154        .map_err(SessionError::from)?;
1155        pool = crate::pool_filters::filter_list_filter(
1156            pool,
1157            &self.arena,
1158            &self.set.repositories,
1159            &self.request,
1160            &self.policy_config,
1161            "update",
1162            &mut warnings,
1163        )
1164        .map_err(SessionError::from)?;
1165        pool.warnings.extend(warnings);
1166        pool.flex_notice = flex_notice;
1167        if std::env::var_os("VIVACITY_TRACE").is_some() {
1168            eprintln!(
1169                "trace: policy filters      {before} → {} package versions ({} removed by lists)",
1170                pool.len(),
1171                pool.filter_list_removed
1172                    .values()
1173                    .map(Vec::len)
1174                    .sum::<usize>()
1175            );
1176        }
1177        Ok(pool)
1178    }
1179
1180    /// `Solver::solve` on this pool; returns the transaction and the
1181    /// decisions (pool literals, in order) with the rule set size.
1182    pub fn solve(
1183        &self,
1184        pool: &Pool,
1185        policy: &mut DefaultPolicy,
1186        filter: &PlatformRequirementFilter,
1187    ) -> Result<SolveReport, SolveError> {
1188        let mut solver = Solver::new(pool, &self.arena);
1189        let transaction = solver.solve(&self.request, policy, filter)?;
1190        let decisions = solver.decisions.queue.iter().map(|d| d.literal).collect();
1191        Ok(SolveReport {
1192            learned: solver
1193                .rules
1194                .ids_of_type(crate::rule::RuleType::Learned)
1195                .len(),
1196            rules: solver.rule_set_size(),
1197            decisions,
1198            transaction,
1199        })
1200    }
1201}
1202
1203/// Running the lock pool through the filter list filter in `install` scope
1204/// (`Installer::doInstall` -> `createFilterListPoolFilter(BLOCK_SCOPE_INSTALL)`):
1205/// Composer's problems for the removed locked versions, in lock order;
1206/// empty when nothing blocks. Warnings (ignored unreachable repositories)
1207/// are returned separately.
1208pub fn install_policy_problems(
1209    project_dir: &Path,
1210    composer_home: Option<&Path>,
1211    http: Option<HttpTransports>,
1212    cache_repo_dir: Option<&Path>,
1213    with_dev: bool,
1214    no_blocking: bool,
1215) -> Result<(Vec<String>, Vec<String>), SessionError> {
1216    let manifest_text = std::fs::read_to_string(project_dir.join("composer.json"))
1217        .map_err(|e| SessionError::new(format!("composer.json: {e}")))?;
1218    let manifest: Value = serde_json::from_str(&manifest_text)
1219        .map_err(|e| SessionError::new(format!("composer.json: {e}")))?;
1220    let config = MergedConfig::load(&manifest, composer_home)?;
1221    let mut policy =
1222        crate::policy_config::PolicyConfig::from_raw(&config.policy).map_err(SessionError::from)?;
1223    policy
1224        .apply_no_blocking(no_blocking)
1225        .map_err(SessionError::from)?;
1226    if !policy.malware_blocks("install") {
1227        return Ok((Vec::new(), Vec::new()));
1228    }
1229    let lock_text = std::fs::read_to_string(project_dir.join("composer.lock"))
1230        .map_err(|e| SessionError::new(format!("composer.lock: {e}")))?;
1231    let lock: Value = serde_json::from_str(&lock_text)
1232        .map_err(|e| SessionError::new(format!("composer.lock: {e}")))?;
1233    // Only `composer` repositories carry lists; the other types (which
1234    // `install` otherwise accepts) are left aside here.
1235    let mut repositories: Vec<Repository> = Vec::new();
1236    for repo in &config.repositories {
1237        if repo.definition.get("type").and_then(Value::as_str) != Some("composer") {
1238            continue;
1239        }
1240        // The constructor does no I/O: an error here is a configuration
1241        // error, fatal as in Composer.
1242        repositories.push(open_repository_with(
1243            repo,
1244            http.as_ref(),
1245            cache_repo_dir,
1246            true,
1247        )?);
1248    }
1249    let mut arena: Vec<Package> = Vec::new();
1250    let locked = crate::repository::locked_repository_with(&lock, &mut arena, with_dev)
1251        .map_err(SessionError::from)?;
1252    let mut request = Request::new(Some(locked.clone()));
1253    for &idx in &locked {
1254        request.fix_locked_package(idx);
1255    }
1256    let pool = Pool::new(locked.clone(), Vec::new(), &arena);
1257    let mut warnings = Vec::new();
1258    let pool = crate::pool_filters::filter_list_filter(
1259        pool,
1260        &arena,
1261        &repositories,
1262        &request,
1263        &policy,
1264        "install",
1265        &mut warnings,
1266    )
1267    .map_err(SessionError::from)?;
1268    let problems: Vec<String> = locked
1269        .iter()
1270        .map(|&idx| &arena[idx])
1271        .filter(|p| pool.is_filter_list_removed(&p.name, &p.version))
1272        .map(|p| crate::pool_filters::locked_removed_problem_text(&pool, p))
1273        .collect();
1274    Ok((problems, warnings))
1275}
1276
1277/// `RepositoryManager::createRepository` for the `composer` and `path`
1278/// types (`vcs` and the others are not supported yet). A `path` repository
1279/// reads its packages now, into the arena, as `origin`.
1280fn open_repository(
1281    repo: &RepoConfig,
1282    http: Option<&HttpTransports>,
1283    cache_repo_dir: Option<&Path>,
1284    project_dir: &Path,
1285    origin: Origin,
1286    arena: &mut Vec<Package>,
1287) -> Result<Repository, SessionError> {
1288    if repo.definition.get("type").and_then(Value::as_str) == Some("path") {
1289        if repo.definition.get("only").is_some()
1290            || repo.definition.get("exclude").is_some()
1291            || repo.definition.get("canonical").is_some()
1292        {
1293            return Err(SessionError::new(format!(
1294                "repository filters (only/exclude/canonical) are not supported by vivacity update yet ({})",
1295                key_string(&repo.key)
1296            )));
1297        }
1298        let path_repo = crate::path_repo::open(&repo.definition, project_dir, origin, arena)
1299            .map_err(SessionError::from)?;
1300        return Ok(Repository::Path(path_repo));
1301    }
1302    open_repository_with(repo, http, cache_repo_dir, false)
1303}
1304
1305/// `for_policies`: a repository with `only`/`exclude`/`canonical`
1306/// (`FilterRepository`) is accepted (the advisory and list paths honor
1307/// `only`/`exclude`) where resolution still rejects it.
1308fn open_repository_with(
1309    repo: &RepoConfig,
1310    http: Option<&HttpTransports>,
1311    cache_repo_dir: Option<&Path>,
1312    for_policies: bool,
1313) -> Result<Repository, SessionError> {
1314    let def = &repo.definition;
1315    let kind = def.get("type").and_then(Value::as_str).ok_or_else(|| {
1316        SessionError::new(format!(
1317            "Repository \"{}\" ({def}) must have a type defined",
1318            key_string(&repo.key)
1319        ))
1320    })?;
1321    if kind != "composer" {
1322        return Err(SessionError::new(format!(
1323            "repository type \"{kind}\" is not supported by vivacity update yet ({})",
1324            key_string(&repo.key)
1325        )));
1326    }
1327    if !for_policies
1328        && (def.get("only").is_some()
1329            || def.get("exclude").is_some()
1330            || def.get("canonical").is_some())
1331    {
1332        return Err(SessionError::new(format!(
1333            "repository filters (only/exclude/canonical) are not supported by vivacity update yet ({})",
1334            key_string(&repo.key)
1335        )));
1336    }
1337    let url = def.get("url").and_then(Value::as_str).ok_or_else(|| {
1338        SessionError::new(format!("repository {} has no url", key_string(&repo.key)))
1339    })?;
1340    let transport: Box<dyn crate::repository::Transport> = if url.starts_with("file://") {
1341        Box::new(FileTransport)
1342    } else if url.starts_with("http://") || url.starts_with("https://") || !url.contains("://") {
1343        match http {
1344            Some(h) => Box::new(HttpTransport {
1345                fetch: h.0.clone(),
1346                fetch_many: h.1.clone(),
1347                post: h.2.clone(),
1348            }),
1349            None => {
1350                return Err(SessionError::new(format!(
1351                    "remote composer repositories need a network transport ({url})"
1352                )))
1353            }
1354        }
1355    } else {
1356        return Err(SessionError::new(format!(
1357            "unsupported repository url scheme ({url})"
1358        )));
1359    };
1360    let mut repo = ComposerRepository::open(url, transport).map_err(SessionError::from)?;
1361    if let Some(options) = def.get("options") {
1362        repo.options = options.clone();
1363    }
1364    repo.set_user_filter(def.get("filter"))
1365        .map_err(SessionError::from)?;
1366    if for_policies {
1367        repo.set_name_filter(def.get("only"), def.get("exclude"))
1368            .map_err(SessionError::from)?;
1369    }
1370    if let Some(dir) = cache_repo_dir {
1371        repo.cache = Some(crate::metacache::MetadataCache::new(dir, &repo.url));
1372    }
1373    Ok(Repository::Composer(Box::new(repo)))
1374}
1375
1376fn key_string(key: &RepoKey) -> String {
1377    match key {
1378        RepoKey::Named(n) => n.clone(),
1379        RepoKey::Indexed(i) => i.to_string(),
1380    }
1381}
1382
1383#[cfg(test)]
1384mod tests {
1385    use super::*;
1386    use serde_json::json;
1387
1388    fn names(repos: &[RepoConfig]) -> Vec<String> {
1389        repos.iter().map(|r| key_string(&r.key)).collect()
1390    }
1391
1392    #[test]
1393    fn merges_repositories_like_composer_config() {
1394        let mut cfg = MergedConfig::load(&json!({}), None).unwrap();
1395        assert_eq!(names(&cfg.repositories), vec!["packagist.org"]);
1396        // global config: snapshot + packagist disabled.
1397        cfg.merge(&json!({"repositories": {"snapshot": {"type": "composer", "url": "file:///s"}, "packagist.org": false}}));
1398        assert_eq!(names(&cfg.repositories), vec!["snapshot"]);
1399        // composer.json: an indexed repository goes first.
1400        cfg.merge(&json!({"repositories": [{"type": "composer", "url": "https://packages.drupal.org/8"}]}));
1401        assert_eq!(names(&cfg.repositories), vec!["0", "snapshot"]);
1402        // two indexed ones while 0 exists: 1 takes its key, 0 is renumbered
1403        // (`$this->repositories[] = ...`); the new ones stay in front.
1404        cfg.merge(
1405            &json!({"repositories": [{"type": "vcs", "url": "a"}, {"type": "vcs", "url": "b"}]}),
1406        );
1407        assert_eq!(names(&cfg.repositories), vec!["2", "1", "0", "snapshot"]);
1408        assert_eq!(cfg.repositories[0].definition["url"], "a");
1409        assert_eq!(cfg.repositories[1].definition["url"], "b");
1410        assert_eq!(
1411            cfg.repositories[2].definition["url"],
1412            "https://packages.drupal.org/8"
1413        );
1414    }
1415
1416    #[test]
1417    fn packagist_url_disables_default() {
1418        let mut cfg = MergedConfig::load(&json!({}), None).unwrap();
1419        cfg.merge(
1420            &json!({"repositories": [{"type": "composer", "url": "https://repo.packagist.org"}]}),
1421        );
1422        assert_eq!(names(&cfg.repositories), vec!["0"]);
1423        let mut cfg = MergedConfig::load(&json!({}), None).unwrap();
1424        cfg.merge(&json!({"repositories": [{"packagist": false}]}));
1425        assert!(cfg.repositories.is_empty());
1426    }
1427}