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