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    /// The message as Composer writes it, piece by piece with each piece's
38    /// level (`IOInterface::QUIET` for the headline of an unsolvable set and
39    /// the `--no-dev` warning, the default level for the problems). Empty
40    /// when the whole message has one level, the caller's.
41    pub parts: Vec<(vivacity_core::io::Verbosity, String)>,
42    /// What Composer hands to `GithubActionError::emit` for this error,
43    /// when it is not the message itself: the headline and the problems of
44    /// an unsolvable set (`$err."\n".$prettyProblem`), without the
45    /// `--no-dev` warning or the dev extraction's two hint lines.
46    pub annotation: Option<String>,
47    /// Lines Composer has written before it throws, which the error must
48    /// not lose: the warnings a degraded repository held while the list
49    /// filter of `install`'s verification ran (`ComposerRepository` writes
50    /// them as it loads, before the exception of another fetch).
51    pub preface: Box<[String]>,
52}
53
54/// What Composer does with the error: an unsolvable set
55/// (`SolverProblemsException`) means exit code 2 from `Installer::run`,
56/// everything else is an exception that propagates.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum SessionErrorKind {
59    Other,
60    Unsolvable,
61    /// Composer would carry this as a `TransportException`, whose code it
62    /// rewrites to 100 (`Application.php:502-506`, `Installer.php:92`).
63    Transport,
64    /// What vivacity does not reproduce (a `path` package versioned by hg,
65    /// fossil or svn): the message is the reason, and the command goes to
66    /// Composer.
67    Unsupported,
68    /// The command prints the message itself and returns a code, with no
69    /// `Error:` prefix and nothing written: `UpdateCommand`'s temporary
70    /// constraint refusal (1), `Installer::run`'s partial update without a
71    /// lock (3).
72    Printed(i32),
73}
74
75impl SessionError {
76    pub fn new(message: impl Into<String>) -> Self {
77        Self {
78            message: message.into(),
79            kind: SessionErrorKind::Other,
80            stdout: None,
81            parts: Vec::new(),
82            annotation: None,
83            preface: Box::default(),
84        }
85    }
86}
87
88impl From<PoolError> for SessionError {
89    fn from(e: PoolError) -> SessionError {
90        let transport = e.is_transport();
91        SessionError {
92            message: e.0,
93            kind: if transport {
94                SessionErrorKind::Transport
95            } else {
96                SessionErrorKind::Other
97            },
98            stdout: None,
99            parts: Vec::new(),
100            annotation: None,
101            preface: Box::default(),
102        }
103    }
104}
105
106/// The newtype errors of the other layers: a message, and nothing to
107/// distinguish — none of them is a transport failure.
108macro_rules! session_error_from {
109    ($($ty:path),+ $(,)?) => {
110        $(impl From<$ty> for SessionError {
111            fn from(e: $ty) -> SessionError {
112                SessionError::new(e.0)
113            }
114        })+
115    };
116}
117
118session_error_from!(
119    crate::loader::LoadError,
120    crate::platform::PlatformError,
121    crate::policy_config::PolicyError,
122    crate::root::RootError,
123);
124
125/// A filter error, on the other hand, can be either.
126impl From<crate::pool_filters::FilterError> for SessionError {
127    fn from(e: crate::pool_filters::FilterError) -> SessionError {
128        SessionError {
129            kind: if e.1 == crate::repository::RepoErrorKind::Transport {
130                SessionErrorKind::Transport
131            } else {
132                SessionErrorKind::Other
133            },
134            message: e.0,
135            stdout: None,
136            parts: Vec::new(),
137            annotation: None,
138            preface: Box::default(),
139        }
140    }
141}
142
143/// A repository error keeps its nature: Composer rewrites a
144/// `TransportException`'s code to `Installer::ERROR_TRANSPORT_EXCEPTION`
145/// (100) before Symfony turns it into an exit code, so a failure to FETCH
146/// something does not exit like a failure to understand it.
147impl From<crate::repository::RepoError> for SessionError {
148    fn from(e: crate::repository::RepoError) -> SessionError {
149        SessionError {
150            kind: match e.1 {
151                crate::repository::RepoErrorKind::Transport => SessionErrorKind::Transport,
152                crate::repository::RepoErrorKind::Unsupported => SessionErrorKind::Unsupported,
153                crate::repository::RepoErrorKind::Data => SessionErrorKind::Other,
154            },
155            message: e.0,
156            stdout: None,
157            parts: Vec::new(),
158            annotation: None,
159            preface: Box::default(),
160        }
161    }
162}
163
164/// `Config::$repositories` after merging: (name or index, definition).
165#[derive(Debug, Clone, PartialEq)]
166pub struct RepoConfig {
167    pub key: RepoKey,
168    pub definition: Value,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum RepoKey {
173    Named(String),
174    Indexed(u64),
175}
176
177/// `Config::merge` for the `repositories` key, applied in order (defaults,
178/// global config, composer.json).
179pub fn merge_repositories(current: &mut Vec<RepoConfig>, new: &Value) {
180    static PACKAGIST: OnceLock<Regex> = OnceLock::new();
181    let entries: Vec<(RepoKey, Value)> = match new {
182        Value::Array(list) => list
183            .iter()
184            .enumerate()
185            .map(|(i, v)| (RepoKey::Indexed(i as u64), v.clone()))
186            .collect(),
187        Value::Object(map) => map
188            .iter()
189            .map(|(k, v)| {
190                let key = match k.parse::<u64>() {
191                    Ok(i) if i.to_string() == *k => RepoKey::Indexed(i),
192                    _ => RepoKey::Named(k.clone()),
193                };
194                (key, v.clone())
195            })
196            .collect(),
197        _ => return,
198    };
199    if entries.is_empty() {
200        return;
201    }
202    current.reverse();
203    // `disableRepoByName((string) $name)`: a numeric string falls back to
204    // the integer key of the PHP array.
205    let disable = |current: &mut Vec<RepoConfig>, name: &str| {
206        let key = match name.parse::<u64>() {
207            Ok(i) if i.to_string() == name => RepoKey::Indexed(i),
208            _ => RepoKey::Named(name.to_owned()),
209        };
210        if current.iter().any(|r| r.key == key) {
211            current.retain(|r| r.key != key);
212        } else if name == "packagist" {
213            current.retain(|r| r.key != RepoKey::Named("packagist.org".into()));
214        }
215    };
216    for (key, repository) in entries.into_iter().rev() {
217        if repository == Value::Bool(false) {
218            let name = match &key {
219                RepoKey::Named(n) => n.clone(),
220                RepoKey::Indexed(i) => i.to_string(),
221            };
222            disable(current, &name);
223            continue;
224        }
225        if let Some(obj) = repository.as_object() {
226            if obj.len() == 1 && obj.values().next() == Some(&Value::Bool(false)) {
227                disable(current, obj.keys().next().map(String::as_str).unwrap_or(""));
228                continue;
229            }
230        }
231        if repository.get("type").and_then(Value::as_str) == Some("composer") {
232            if let Some(url) = repository.get("url").and_then(Value::as_str) {
233                let re = regex(
234                    &PACKAGIST,
235                    r"^https?://(?:[a-z0-9-.]+\.)?packagist.org(/|$)",
236                    false,
237                );
238                if re.is_match(url.as_bytes()).unwrap_or(false) {
239                    disable(current, "packagist.org");
240                }
241            }
242        }
243        match key {
244            RepoKey::Indexed(i) => {
245                if current.iter().any(|r| r.key == RepoKey::Indexed(i)) {
246                    let next = current
247                        .iter()
248                        .filter_map(|r| match r.key {
249                            RepoKey::Indexed(j) => Some(j + 1),
250                            _ => None,
251                        })
252                        .max()
253                        .unwrap_or(0);
254                    current.push(RepoConfig {
255                        key: RepoKey::Indexed(next),
256                        definition: repository,
257                    });
258                } else {
259                    current.push(RepoConfig {
260                        key: RepoKey::Indexed(i),
261                        definition: repository,
262                    });
263                }
264            }
265            RepoKey::Named(name) => {
266                let name = if name == "packagist" {
267                    "packagist.org".to_owned()
268                } else {
269                    name
270                };
271                let key = RepoKey::Named(name);
272                if let Some(slot) = current.iter_mut().find(|r| r.key == key) {
273                    slot.definition = repository;
274                } else {
275                    current.push(RepoConfig {
276                        key,
277                        definition: repository,
278                    });
279                }
280            }
281        }
282    }
283    current.reverse();
284}
285
286/// Merged configuration relevant to the resolver.
287#[derive(Debug, Clone, Default)]
288pub struct MergedConfig {
289    pub repositories: Vec<RepoConfig>,
290    /// `config.platform` (the last definition replaces the previous one).
291    pub platform: Map<String, Value>,
292    /// `config.policy` and `config.audit` merged (`Config::merge`).
293    pub policy: crate::policy_config::RawPolicyConfig,
294}
295
296impl MergedConfig {
297    /// Defaults + `COMPOSER_HOME/config.json` + composer.json, like
298    /// `Factory::createConfig` then `Config::merge($localConfig)`.
299    pub fn load(
300        manifest: &Value,
301        composer_home: Option<&Path>,
302    ) -> Result<MergedConfig, SessionError> {
303        let mut cfg = MergedConfig {
304            repositories: vec![RepoConfig {
305                key: RepoKey::Named("packagist.org".into()),
306                definition: serde_json::json!({"type": "composer", "url": "https://repo.packagist.org"}),
307            }],
308            platform: Map::new(),
309            policy: crate::policy_config::RawPolicyConfig::default(),
310        };
311        if let Some(home) = composer_home {
312            let global = home.join("config.json");
313            if global.is_file() {
314                let text = std::fs::read_to_string(&global)
315                    .map_err(|e| SessionError::new(format!("{}: {e}", global.display())))?;
316                let v: Value = serde_json::from_str(&text)
317                    .map_err(|e| SessionError::new(format!("{}: {e}", global.display())))?;
318                cfg.merge(&v);
319            }
320        }
321        cfg.merge(manifest);
322        Ok(cfg)
323    }
324
325    fn merge(&mut self, config: &Value) {
326        // `$this->config['platform'] = $val`: any value replaces the
327        // previous one (`[]` or `null` clear it).
328        if let Some(platform) = config.get("config").and_then(|c| c.get("platform")) {
329            self.platform = platform.as_object().cloned().unwrap_or_default();
330        }
331        if let Some(repos) = config.get("repositories") {
332            merge_repositories(&mut self.repositories, repos);
333        }
334        if let Some(cfg) = config.get("config").and_then(Value::as_object) {
335            self.policy.merge(cfg);
336        }
337    }
338}
339
340/// Result of a `solve`: the transaction and what is needed to compare it
341/// with Composer.
342pub struct SolveReport {
343    pub transaction: LockTransaction,
344    /// Decided literals, in order.
345    pub decisions: Vec<i64>,
346    /// `getRuleSetSize()`.
347    pub rules: usize,
348    /// Learned rules (conflicts encountered).
349    pub learned: usize,
350    /// What `Installer::doUpdate` writes after a successful resolution and
351    /// before the lock operations, in order, each line with its level: the
352    /// resolution's duration, the `Analyzed …` counts, `Nothing to modify in
353    /// lock file`, the dev extraction's duration. Held rather than written so
354    /// that the command prints them once it knows the run stays here.
355    pub events: Vec<(vivacity_core::io::Verbosity, String)>,
356}
357
358/// Options of an `update` (`Installer::setUpdateAllowList`,
359/// `setUpdateAllowTransitiveDependencies`).
360#[derive(Debug, Clone, Default)]
361pub struct UpdateOptions {
362    /// `composer update a/b c/*`: patterns, lowercased and deduplicated.
363    pub allow_list: Vec<String>,
364    pub transitive: Option<crate::pool::UpdateMode>,
365    /// `--no-blocking` / `--no-security-blocking`.
366    pub no_blocking: bool,
367    /// Dry run of `require`/`remove`: the root package is patched in
368    /// memory, composer.json is not touched.
369    pub root_patch: Option<crate::root::RootPatch>,
370    /// composer-merge-plugin active: the root as its INIT /
371    /// `PRE_UPDATE_CMD` merges left it (the merged manifest, the
372    /// included files' own requirements for the stability flags).
373    pub merged: Option<crate::merge_plugin::Merged>,
374    /// `update --with a/b:^1` (and the `update a/b:^1` shorthand), as
375    /// written: name → constraint text, in command order. Parsed and
376    /// checked against the root requirements by `prepare_update`.
377    pub temporary_requirements: Vec<(String, String)>,
378    /// `config.lock: false`: `Factory` gives the `Locker` `/dev/null`, so
379    /// `isLocked()` is false and the update runs without a locked
380    /// repository, whatever composer.lock holds (Factory.php:423-429).
381    pub ignore_lock: bool,
382}
383
384impl UpdateOptions {
385    /// `Installer::setUpdateAllowList`: `strtolower` + `array_unique`.
386    pub fn partial(packages: &[String], transitive: crate::pool::UpdateMode) -> UpdateOptions {
387        let mut allow_list: Vec<String> = Vec::new();
388        for p in packages {
389            let l = p.to_lowercase();
390            if !allow_list.contains(&l) {
391                allow_list.push(l);
392            }
393        }
394        UpdateOptions {
395            allow_list,
396            transitive: Some(transitive),
397            ..Default::default()
398        }
399    }
400}
401
402/// `UpdateCommand::execute`'s temporary-constraint block: the root's
403/// references and stability flags take the requirements in, a name with
404/// `*` expands over the root requirements, and every constraint must
405/// intersect the one in composer.json — otherwise the command fails with
406/// its own message (exit 1), before anything is written.
407fn temporary_constraints(
408    reqs: &[(String, String)],
409    root: &mut RootPackage,
410) -> Result<BTreeMap<String, crate::pool::TemporaryConstraint>, SessionError> {
411    if reqs.is_empty() {
412        return Ok(BTreeMap::new());
413    }
414    let pairs: Vec<(String, String)> = reqs.to_vec();
415    crate::root::extract_references(&pairs, &mut root.references);
416    let minimum = root.minimum_stability.clone();
417    crate::root::extract_stability_flags(&pairs, &minimum, &mut root.stability_flags);
418    // `$rootRequirements = array_merge(getRequires(), getDevRequires())`:
419    // composer.json's order, which is the order a wildcard reports its
420    // first mismatch in.
421    let root_requires: Vec<(String, String, Constraint)> = root
422        .all_requires()
423        .iter()
424        .map(|l| {
425            (
426                l.target.clone(),
427                l.pretty_constraint.clone(),
428                l.constraint.clone(),
429            )
430        })
431        .collect();
432    let fail = |message: String, stdout: Option<String>| SessionError {
433        message,
434        kind: SessionErrorKind::Printed(1),
435        stdout,
436        parts: Vec::new(),
437        annotation: None,
438        preface: Box::default(),
439    };
440    let mut out: BTreeMap<String, crate::pool::TemporaryConstraint> = BTreeMap::new();
441    for (name, text) in &pairs {
442        let name = name.to_lowercase();
443        let parsed = crate::pool::TemporaryConstraint {
444            pretty: text.clone(),
445            constraint: crate::constraint::parse_constraints(text)
446                .map_err(|e| SessionError::new(format!("{name}: {e}")))?
447                .constraint,
448        };
449        if name.contains('*') {
450            let re = crate::pool::package_name_regexp(&name);
451            for (target, pretty, constraint) in &root_requires {
452                if !re.is_match(target.as_bytes()).unwrap_or(false) {
453                    continue;
454                }
455                out.insert(target.clone(), parsed.clone());
456                if !crate::intervals::have_intersections(&parsed.constraint, constraint) {
457                    return Err(fail(
458                        format!(
459                            "The temporary constraint \"{text}\" for \"{name}\" matching \"{target}\" must be a subset of the constraint in your composer.json ({pretty})"
460                        ),
461                        None,
462                    ));
463                }
464            }
465        } else {
466            out.insert(name.clone(), parsed.clone());
467            if let Some((_, pretty, constraint)) = root_requires.iter().find(|(t, _, _)| *t == name)
468            {
469                if !crate::intervals::have_intersections(&parsed.constraint, constraint) {
470                    return Err(fail(
471                        format!(
472                            "The temporary constraint \"{text}\" for \"{name}\" must be a subset of the constraint in your composer.json ({pretty})"
473                        ),
474                        Some(format!(
475                            "Run `composer require {name}` or `composer require {name}:{text}` instead to replace the constraint"
476                        )),
477                    ));
478                }
479            }
480        }
481    }
482    Ok(out)
483}
484
485/// Everything `Installer::doUpdate` has at hand right before `createPool`.
486/// symfony/flex active for this resolution: its requirement and index.
487#[derive(Debug, Clone)]
488pub struct FlexSession {
489    pub symfony_require: String,
490    pub symfony: Constraint,
491    pub versions: crate::flex_filter::FlexVersions,
492}
493
494pub struct UpdateSession {
495    /// `None`: no Flex filter at `PRE_POOL_CREATE`.
496    pub flex: Option<FlexSession>,
497    pub arena: Vec<Package>,
498    pub root: RootPackage,
499    /// Arena indices of the fixed root (requires emptied) and of its alias.
500    pub fixed_root: usize,
501    pub fixed_root_alias: Option<usize>,
502    pub platform: Vec<usize>,
503    pub locked: Option<Vec<usize>>,
504    pub set: RepositorySet,
505    pub request: Request,
506    pub config: MergedConfig,
507    pub dev_mode: bool,
508    /// `--prefer-stable` / `--prefer-lowest` from the command line.
509    pub prefer_stable: bool,
510    pub prefer_lowest: bool,
511    /// `PHP_MAJOR.MINOR.RELEASE` of the probed PHP (`ext-*` rule of the
512    /// `VersionSelector`).
513    pub php_version: String,
514    /// The pool blocking policies (`createPolicyConfig`).
515    pub policy_config: crate::policy_config::PolicyConfig,
516    /// `XdebugHandler::getAllIniFiles()` of the probed PHP (extension hint
517    /// of an unsolvable set).
518    pub ini_files: Vec<String>,
519    /// `ext-*` loaded by the probed PHP, before `config.platform`.
520    pub loaded_extensions: BTreeSet<String>,
521    /// `Installer::$devMode` (`--no-dev` / `--update-no-dev` clear it):
522    /// only the `--no-dev` warning of an unsolvable set reads it.
523    pub installer_dev_mode: bool,
524}
525
526impl UpdateSession {
527    pub fn prepare(
528        project_dir: &Path,
529        composer_home: Option<&Path>,
530        dev_mode: bool,
531    ) -> Result<UpdateSession, SessionError> {
532        Self::prepare_with(project_dir, composer_home, dev_mode, None)
533    }
534
535    /// Like `prepare`, with a network transport for `https://` repositories.
536    pub fn prepare_with(
537        project_dir: &Path,
538        composer_home: Option<&Path>,
539        dev_mode: bool,
540        http: Option<HttpTransports>,
541    ) -> Result<UpdateSession, SessionError> {
542        Self::prepare_full(project_dir, composer_home, dev_mode, http, None)
543    }
544
545    /// Like `prepare_with`, with Composer's `cache-repo-dir` directory for
546    /// the metadata cache (read and written in Composer's format).
547    pub fn prepare_full(
548        project_dir: &Path,
549        composer_home: Option<&Path>,
550        dev_mode: bool,
551        http: Option<HttpTransports>,
552        cache_repo_dir: Option<&Path>,
553    ) -> Result<UpdateSession, SessionError> {
554        Self::prepare_update(
555            project_dir,
556            composer_home,
557            dev_mode,
558            http,
559            cache_repo_dir,
560            &UpdateOptions::default(),
561        )
562    }
563
564    /// Like `prepare_full`, with the allow list of a partial update
565    /// (`composer update a/b`).
566    pub fn prepare_update(
567        project_dir: &Path,
568        composer_home: Option<&Path>,
569        dev_mode: bool,
570        http: Option<HttpTransports>,
571        cache_repo_dir: Option<&Path>,
572        options: &UpdateOptions,
573    ) -> Result<UpdateSession, SessionError> {
574        let partial_update = !options.allow_list.is_empty();
575        let manifest_path = project_dir.join("composer.json");
576        let manifest_text = std::fs::read_to_string(&manifest_path)
577            .map_err(|e| SessionError::new(format!("{}: {e}", manifest_path.display())))?;
578        let manifest: Value = serde_json::from_str(&manifest_text)
579            .map_err(|e| SessionError::new(format!("{}: {e}", manifest_path.display())))?;
580        let config = MergedConfig::load(&manifest, composer_home)?;
581        let mut policy_config = crate::policy_config::PolicyConfig::from_raw(&config.policy)
582            .map_err(SessionError::from)?;
583        policy_config
584            .apply_no_blocking(options.no_blocking)
585            .map_err(SessionError::from)?;
586        let mut root = RootPackage::load(&manifest, project_dir).map_err(SessionError::from)?;
587        if let Some(merged) = &options.merged {
588            // `RootPackageLoader` ran on the file: its stability flags are
589            // the starting point. The plugin then merges each file's
590            // links, aliases and references into the root; the flags of a
591            // merged file come from its own `require` (and `require-dev`
592            // in dev mode), `mergeStabilityFlags` — not from the merged
593            // constraint text, which the loader never sees.
594            let flags = std::mem::take(&mut root.stability_flags);
595            root = RootPackage::load(&merged.manifest, project_dir).map_err(SessionError::from)?;
596            root.stability_flags = flags;
597            // (`require_dev` is recorded only when the merge ran in dev
598            // mode — `mergeDevInto`, a second pass over the files.)
599            for include in &merged.include_links {
600                crate::root::merge_plugin_stability_flags(
601                    &mut root.stability_flags,
602                    &root.minimum_stability,
603                    &include.require,
604                );
605            }
606            for include in &merged.include_links {
607                crate::root::merge_plugin_stability_flags(
608                    &mut root.stability_flags,
609                    &root.minimum_stability,
610                    &include.require_dev,
611                );
612            }
613        }
614        if let Some(patch) = &options.root_patch {
615            root.apply_patch(patch).map_err(SessionError::from)?;
616        }
617        // `UpdateCommand`: the temporary requirements feed the root's
618        // references and stability flags before anything else reads them,
619        // then become the pool's temporary constraints.
620        let temporary_constraints =
621            temporary_constraints(&options.temporary_requirements, &mut root)?;
622        let probed = probe().map_err(SessionError::from)?;
623        // `PHP_MAJOR_VERSION.PHP_MINOR_VERSION.PHP_RELEASE_VERSION` of the
624        // actual PHP (no `config.platform.php` here): the first three
625        // numbers of PHP_VERSION.
626        let php_version = probed
627            .iter()
628            .find(|p| p.get("name").and_then(Value::as_str) == Some("php"))
629            .and_then(|p| p.get("version").and_then(Value::as_str))
630            .map(|v| {
631                v.split(['.', '-', '+'])
632                    .take(3)
633                    .map(|part| part.trim_end_matches(|c: char| !c.is_ascii_digit()))
634                    .collect::<Vec<_>>()
635                    .join(".")
636            })
637            .unwrap_or_default();
638        let platform_pkgs =
639            platform_packages(&probed, &config.platform).map_err(SessionError::from)?;
640
641        let mut arena: Vec<Package> = Vec::new();
642
643        // `$fixedRootPackage = clone $package; setRequires([]); setDevRequires([])`.
644        let mut fixed = root.package.clone();
645        fixed.requires = Default::default();
646        fixed.dev_requires = Default::default();
647        arena.push(fixed);
648        let fixed_root = 0;
649        let fixed_root_alias = root.branch_alias.as_ref().map(|(normalized, pretty)| {
650            let a = arena[fixed_root].alias(fixed_root, normalized, pretty);
651            arena.push(a);
652            arena.len() - 1
653        });
654        let root_members: Vec<usize> = fixed_root_alias
655            .into_iter()
656            .chain(std::iter::once(fixed_root))
657            .collect();
658
659        let mut platform: Vec<usize> = Vec::new();
660        for p in platform_pkgs {
661            arena.push(p);
662            platform.push(arena.len() - 1);
663        }
664
665        // `Locker::isLocked()` = file present and `isset($data['packages'])`;
666        // an unreadable lock is ignored for a full update (`doUpdate`
667        // swallows the ParsingException), fatal for a partial one.
668        let lock_path = project_dir.join("composer.lock");
669        let lock: Option<Value> = if lock_path.is_file() && !options.ignore_lock {
670            let text = std::fs::read_to_string(&lock_path)
671                .map_err(|e| SessionError::new(format!("{}: {e}", lock_path.display())))?;
672            match serde_json::from_str::<Value>(&text) {
673                Ok(v) if v.get("packages").is_some_and(|p| !p.is_null()) => Some(v),
674                Ok(_) => None,
675                Err(e) => {
676                    if partial_update {
677                        return Err(SessionError::new(format!(
678                            "\"{}\" does not contain valid JSON\n{e}",
679                            lock_path.display()
680                        )));
681                    }
682                    None
683                }
684            }
685        } else {
686            None
687        };
688        let locked = match &lock {
689            Some(v) => Some(locked_repository(v, &mut arena).map_err(SessionError::from)?),
690            None => None,
691        };
692
693        // `$stabilityFlags[$package->getName()] = STABILITIES[parseStability($package->getVersion())]`:
694        // the version seen is that of the root alias if there is one.
695        let mut stability_flags = root.stability_flags.clone();
696        let root_version = match fixed_root_alias {
697            Some(a) => arena[a].version.clone(),
698            None => arena[fixed_root].version.clone(),
699        };
700        stability_flags.insert(
701            root.package.name.clone(),
702            stability_rank(parse_stability(&root_version)),
703        );
704
705        // `createRepositorySet(forUpdate)` and `requirePackagesForUpdate(..., true)`
706        // always take require + require-dev, `--no-dev` or not; and with a
707        // root alias, `$this->package` is the RootAliasPackage, whose
708        // `self.version` links target the alias version.
709        let mut root_requires: OrderedMap<Constraint> = OrderedMap::default();
710        let requires = match &root.branch_alias {
711            Some((normalized, pretty)) => {
712                let aliased = root.package.alias(0, normalized, pretty);
713                let mut all = aliased.requires.clone();
714                for l in aliased.dev_requires.iter() {
715                    all.insert(l.clone());
716                }
717                all
718            }
719            None => root.all_requires(),
720        };
721        for link in requires.iter() {
722            root_requires.insert(&link.target, link.constraint.clone());
723        }
724
725        let mut set = RepositorySet::new(
726            &root.minimum_stability,
727            stability_flags,
728            &root.aliases,
729            root.references.clone(),
730            root_requires,
731            temporary_constraints,
732        );
733        set.add_repository(Repository::Root(root_members));
734        set.add_repository(Repository::Platform(platform.clone()));
735        for repo in &config.repositories {
736            let origin = Origin::Repository(set.repositories.len());
737            set.add_repository(open_repository(
738                repo,
739                http.as_ref(),
740                cache_repo_dir,
741                project_dir,
742                origin,
743                &mut arena,
744            )?);
745        }
746        if let Some(ids) = &locked {
747            set.add_repository(Repository::Locked(ids.clone()));
748        }
749
750        // `Installer::run`: a partial update requires a lock — the message
751        // alone on stderr, exit 3.
752        if partial_update && locked.is_none() {
753            return Err(SessionError {
754                message: "Cannot update only a partial set of packages without a lock file present. Run `composer update` to generate a lock file.".to_owned(),
755                kind: SessionErrorKind::Printed(3),
756                stdout: None,
757                // `Installer.php:508`: at QUIET.
758                parts: vec![(
759                    vivacity_core::io::Verbosity::Quiet,
760                    "Cannot update only a partial set of packages without a lock file present. Run `composer update` to generate a lock file.".to_owned(),
761                )],
762                annotation: None,
763                preface: Box::default(),
764            });
765        }
766        let mut request = Request::new(locked.clone());
767        if partial_update {
768            request.set_update_allow_list(
769                options.allow_list.clone(),
770                options
771                    .transitive
772                    .unwrap_or(crate::pool::UpdateMode::OnlyListed),
773            );
774        }
775        if let Some(a) = fixed_root_alias {
776            request.fix_package(a);
777        }
778        request.fix_package(fixed_root);
779        for &p in &platform {
780            let provided = arena[fixed_root]
781                .provides
782                .get(&arena[p].name)
783                .map(|l| l.constraint.clone());
784            let provided_here = provided
785                .is_some_and(|c| c.matches(&Constraint::new(Op::Eq, arena[p].version.clone())));
786            if !provided_here {
787                request.fix_package(p);
788            }
789        }
790        for link in requires.iter() {
791            request.require_name_pretty(
792                &link.target,
793                Some(link.constraint.clone()),
794                &link.pretty_constraint,
795            )?;
796        }
797
798        Ok(UpdateSession {
799            flex: None,
800            arena,
801            root,
802            fixed_root,
803            fixed_root_alias,
804            platform,
805            locked,
806            set,
807            request,
808            config,
809            dev_mode,
810            prefer_stable: false,
811            prefer_lowest: false,
812            php_version,
813            policy_config,
814            ini_files: crate::platform::ini_files(&probed),
815            loaded_extensions: crate::platform::loaded_extensions(&probed),
816            installer_dev_mode: true,
817        })
818    }
819
820    pub fn create_pool(&mut self) -> Result<Pool, SessionError> {
821        let pre_pool = self.flex.as_ref().map(|f| {
822            // `$rootPackage->getRequires() + $rootPackage->getDevRequires()`:
823            // a name in both keeps the `require` constraint.
824            let mut root_constraints = BTreeMap::new();
825            for link in self
826                .root
827                .package
828                .requires
829                .0
830                .iter()
831                .chain(self.root.package.dev_requires.0.iter())
832            {
833                root_constraints
834                    .entry(link.target.clone())
835                    .or_insert_with(|| link.constraint.clone());
836            }
837            crate::pool::PrePoolFilter {
838                symfony_require: f.symfony_require.clone(),
839                symfony: f.symfony.clone(),
840                root_constraints,
841                versions: f.versions.clone(),
842            }
843        });
844        Ok(self.set.create_pool(
845            &mut self.request,
846            &mut self.arena,
847            pre_pool.as_ref(),
848            crate::pool::INSTALLER_IGNORED_TYPES,
849        )?)
850    }
851
852    /// `Installer::createPolicy(true, ...)` without `--minimal-changes`.
853    /// With Flex active, `COMPOSER_PREFER_DEV_OVER_PRERELEASE` is what Flex
854    /// sets before Composer builds the policy.
855    pub fn policy(&self) -> DefaultPolicy {
856        let mut policy = DefaultPolicy::new(
857            self.prefer_stable || self.root.prefer_stable,
858            self.prefer_lowest,
859            None,
860        );
861        if self.flex.is_some() {
862            policy.prefer_dev_over_prerelease = true;
863        }
864        policy
865    }
866
867    /// `Installer::extractDevPackages`: second solve without the
868    /// require-dev, on only the packages kept by the first, to classify
869    /// `packages` / `packages-dev`.
870    pub fn extract_dev_packages(
871        &mut self,
872        transaction: &mut LockTransaction,
873        policy: &mut DefaultPolicy,
874        filter: &PlatformRequirementFilter,
875        events: &mut Vec<(vivacity_core::io::Verbosity, String)>,
876    ) -> Result<(), SessionError> {
877        if self.root.package.dev_requires.is_empty() {
878            return Ok(());
879        }
880        // `$resultRepo`: each package reloaded from its dump (`load`, one at
881        // a time), branch aliases recreated -> [alias, base].
882        let dumps: Vec<Value> = transaction
883            .new_lock_packages(&self.arena, false)
884            .iter()
885            .map(|&idx| Value::Object(dump_package(&self.arena[idx])))
886            .collect();
887        let result_ids =
888            crate::loader::load_packages(&dumps, Origin::Result, &mut self.arena, false)
889                .map_err(SessionError::from)?;
890        // createPoolWithAllPackages: root, platform, result, with the root
891        // aliases applied along the way.
892        let mut members: Vec<usize> = Vec::new();
893        members.extend(self.fixed_root_alias);
894        members.push(self.fixed_root);
895        members.extend(self.platform.iter().copied());
896        members.extend(result_ids);
897        let mut pool_packages: Vec<usize> = Vec::new();
898        for idx in members {
899            pool_packages.push(idx);
900            let (name, version) = (
901                self.arena[idx].name.clone(),
902                self.arena[idx].version.clone(),
903            );
904            if let Some((alias, alias_normalized)) = self
905                .set
906                .root_aliases
907                .get(&name)
908                .and_then(|m| m.get(&version))
909            {
910                let mut base = idx;
911                while let Some(b) = self.arena[base].alias_of {
912                    base = b;
913                }
914                let mut a = self.arena[base].alias(base, alias_normalized, alias);
915                a.root_package_alias = true;
916                a.origin = Origin::Detached;
917                self.arena.push(a);
918                pool_packages.push(self.arena.len() - 1);
919            }
920        }
921        let pool = Pool::new(pool_packages, Vec::new(), &self.arena);
922        // createRequest (without lock) + requirePackagesForUpdate(..., false).
923        let mut request = Request::new(None);
924        if let Some(a) = self.fixed_root_alias {
925            request.fix_package(a);
926        }
927        request.fix_package(self.fixed_root);
928        for &p in &self.platform {
929            let provided = self.arena[self.fixed_root]
930                .provides
931                .get(&self.arena[p].name)
932                .map(|l| l.constraint.clone());
933            let provided_here = provided.is_some_and(|c| {
934                c.matches(&Constraint::new(Op::Eq, self.arena[p].version.clone()))
935            });
936            if !provided_here {
937                request.fix_package(p);
938            }
939        }
940        let requires = match &self.root.branch_alias {
941            Some((normalized, pretty)) => self.root.package.alias(0, normalized, pretty).requires,
942            None => self.root.package.requires.clone(),
943        };
944        for link in requires.iter() {
945            request.require_name_pretty(
946                &link.target,
947                Some(link.constraint.clone()),
948                &link.pretty_constraint,
949            )?;
950        }
951        let mut solver = Solver::new(&pool, &self.arena);
952        let before = std::time::Instant::now();
953        let solved = solver.solve(&request, policy, filter);
954        // `Solver::solve`: the duration at VERBOSE, before any problem —
955        // held with the caller's lines on success.
956        events.push(resolution_completed(before));
957        let non_dev = match solved {
958            Ok(t) => t,
959            Err(SolveError::Problems(problems)) => {
960                drop(solver);
961                // `Installer::extractDevPackages`: `$isDevExtraction`, on a
962                // request without lock built for this solve.
963                let pretty = {
964                    let mut ctx = crate::problem::MessageContext {
965                        arena: &mut self.arena,
966                        set: &self.set,
967                        pool: &pool,
968                        request: &request,
969                        is_verbose: vivacity_core::io::shows(vivacity_core::io::Verbosity::Verbose),
970                        ini_files: &self.ini_files,
971                        loaded_extensions: &self.loaded_extensions,
972                    };
973                    crate::problem::pretty_string(&mut ctx, &problems, true)
974                };
975                // `Installer::extractDevPackages`: the headline at QUIET, the
976                // rest at the default level.
977                let head = "Unable to find a compatible set of packages based on your non-dev requirements alone.";
978                let rest = format!("Your 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}");
979                return Err(SessionError {
980                    message: format!("{head}\n{rest}"),
981                    kind: SessionErrorKind::Unsolvable,
982                    stdout: None,
983                    parts: vec![
984                        (vivacity_core::io::Verbosity::Quiet, head.to_owned()),
985                        (vivacity_core::io::Verbosity::Normal, rest),
986                    ],
987                    // `$ghe->emit($err."\n".$prettyProblem)` (Installer.php:735).
988                    annotation: Some(format!("{head}\n{pretty}")),
989                    preface: Box::default(),
990                });
991            }
992            Err(SolveError::Bug(b)) => return Err(SessionError::new(b)),
993        };
994        transaction.set_non_dev_packages(&self.arena, &non_dev);
995        Ok(())
996    }
997
998    /// `extractPlatformRequirements($links)`.
999    fn platform_requirements(links: &crate::package::Links) -> Map<String, Value> {
1000        let mut out = Map::new();
1001        for l in links.iter() {
1002            if crate::platform::is_platform_package(&l.target) {
1003                out.insert(l.target.clone(), Value::String(l.pretty_constraint.clone()));
1004            }
1005        }
1006        out
1007    }
1008
1009    /// `Locker::setLockData(...)`: the lock JSON to write.
1010    pub fn lock_json(
1011        &self,
1012        transaction: &LockTransaction,
1013        manifest_text: &str,
1014    ) -> Result<Value, SessionError> {
1015        let content_hash = vivacity_core::content_hash::content_hash(manifest_text)
1016            .map_err(|e| SessionError::new(e.to_string()))?;
1017        let packages = lock_packages(
1018            &self.arena,
1019            &transaction.new_lock_packages(&self.arena, false),
1020        )
1021        .map_err(SessionError::new)?;
1022        let packages_dev = lock_packages(
1023            &self.arena,
1024            &transaction.new_lock_packages(&self.arena, true),
1025        )
1026        .map_err(SessionError::new)?;
1027        let (requires, dev_requires) = match &self.root.branch_alias {
1028            Some((normalized, pretty)) => {
1029                let a = self.root.package.alias(0, normalized, pretty);
1030                (a.requires, a.dev_requires)
1031            }
1032            None => (
1033                self.root.package.requires.clone(),
1034                self.root.package.dev_requires.clone(),
1035            ),
1036        };
1037        Ok(lock_data(LockInput {
1038            content_hash: &content_hash,
1039            packages,
1040            packages_dev: Some(packages_dev),
1041            platform: Self::platform_requirements(&requires),
1042            platform_dev: Self::platform_requirements(&dev_requires),
1043            aliases: &transaction.aliases(&self.arena, &self.root.aliases),
1044            minimum_stability: &self.root.minimum_stability,
1045            stability_flags: &self.root.stability_flags,
1046            prefer_stable: self.prefer_stable || self.root.prefer_stable,
1047            prefer_lowest: self.prefer_lowest,
1048            platform_overrides: &self.config.platform,
1049        }))
1050    }
1051
1052    /// Full `composer update --no-install`: solve, dev package extraction,
1053    /// lock data. Returns the lock JSON and the report of the first solve.
1054    pub fn update(
1055        &mut self,
1056        manifest_text: &str,
1057        filter: &PlatformRequirementFilter,
1058    ) -> Result<(Value, SolveReport), SessionError> {
1059        let trace = std::env::var_os("VIVACITY_TRACE").is_some();
1060        let t = std::time::Instant::now();
1061        let lap = |label: &str, t: &std::time::Instant| {
1062            if trace {
1063                eprintln!(
1064                    "trace: {label:<22} {:>7.1} ms",
1065                    t.elapsed().as_secs_f64() * 1000.0
1066                );
1067            }
1068        };
1069        let mut policy = self.policy();
1070        let pool = self.create_filtered_pool()?;
1071        // `Installer::doUpdate`: the pool is built (Flex's notice comes out
1072        // of it), then `Updating dependencies`, then the policy filters'
1073        // warnings as they happened.
1074        if let Some(notice) = &pool.flex_notice {
1075            vivacity_core::say!("{notice}");
1076        }
1077        vivacity_core::say!("Updating dependencies");
1078        for w in &pool.warnings {
1079            vivacity_core::say!("{w}");
1080        }
1081        lap("pool", &t);
1082        let pool = if std::env::var("COMPOSER_POOL_OPTIMIZER").as_deref() == Ok("0") {
1083            pool
1084        } else {
1085            PoolOptimizer::new().optimize(&self.request, &pool, &self.arena, &mut policy)
1086        };
1087        lap("optimize", &t);
1088        let before = std::time::Instant::now();
1089        let solved = self.solve(&pool, &mut policy, filter);
1090        let completed = resolution_completed(before);
1091        let mut report = match solved {
1092            Ok(mut r) => {
1093                r.events.push(completed);
1094                r
1095            }
1096            Err(SolveError::Problems(problems)) => {
1097                // `Solver::solve` wrote its duration before throwing.
1098                say_event(&completed);
1099                // `Installer::doUpdate`: the headline, the pretty problems,
1100                // the `--no-dev` warning.
1101                // The headline and the warning at QUIET, the problems at the
1102                // default level.
1103                let pretty = self.pretty_problems(&pool, &problems, false);
1104                let head =
1105                    "Your requirements could not be resolved to an installable set of packages.";
1106                // `$ghe->emit($err."\n".$prettyProblem)` (Installer.php:554).
1107                let annotation = Some(format!("{head}\n{pretty}"));
1108                let mut parts = vec![
1109                    (vivacity_core::io::Verbosity::Quiet, head.to_owned()),
1110                    (vivacity_core::io::Verbosity::Normal, pretty),
1111                ];
1112                if !self.installer_dev_mode {
1113                    parts.push((vivacity_core::io::Verbosity::Quiet, "Running 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.".to_owned()));
1114                }
1115                let message = parts
1116                    .iter()
1117                    .map(|(_, p)| p.as_str())
1118                    .collect::<Vec<_>>()
1119                    .join("\n");
1120                return Err(SessionError {
1121                    message,
1122                    kind: SessionErrorKind::Unsolvable,
1123                    stdout: None,
1124                    parts,
1125                    annotation,
1126                    preface: Box::default(),
1127                });
1128            }
1129            Err(SolveError::Bug(b)) => {
1130                say_event(&completed);
1131                return Err(SessionError::new(b));
1132            }
1133        };
1134        lap("solve", &t);
1135        // `ValidatingArrayLoader::validatePackage` on every kept package
1136        // (`LockTransaction::setResultPackages`, inside `Solver::solve`): a
1137        // SecurityException stops the update before anything else is written.
1138        for &idx in &report.transaction.all {
1139            if let Err(e) = crate::lockfile::validate_package(&self.arena[idx]) {
1140                say_events(&report.events);
1141                return Err(SessionError::new(e));
1142            }
1143        }
1144        // `Installer::doUpdate`, in its order: the pool and rule set sizes
1145        // at VERBOSE, `Nothing to modify in lock file`, then the dev
1146        // extraction (its own resolution line).
1147        use vivacity_core::io::Verbosity;
1148        report.events.push((
1149            Verbosity::Verbose,
1150            format!("Analyzed {} packages to resolve dependencies", pool.len()),
1151        ));
1152        report.events.push((
1153            Verbosity::Verbose,
1154            format!("Analyzed {} rules to resolve dependencies", report.rules),
1155        ));
1156        if report.transaction.transaction.operations.is_empty() {
1157            report.events.push((
1158                Verbosity::Normal,
1159                "Nothing to modify in lock file".to_owned(),
1160            ));
1161        }
1162        drop(pool);
1163        let mut transaction = std::mem::replace(&mut report.transaction, LockTransaction::empty());
1164        if let Err(e) =
1165            self.extract_dev_packages(&mut transaction, &mut policy, filter, &mut report.events)
1166        {
1167            // Composer wrote the lines above before the extraction failed.
1168            say_events(&report.events);
1169            return Err(e);
1170        }
1171        lap("extract dev", &t);
1172        let lock = self.lock_json(&transaction, manifest_text)?;
1173        lap("lock data", &t);
1174        report.transaction = transaction;
1175        Ok((lock, report))
1176    }
1177
1178    /// `SolverProblemsException::getPrettyString` for problems found on
1179    /// `pool` with this session's request.
1180    pub fn pretty_problems(
1181        &mut self,
1182        pool: &Pool,
1183        problems: &[crate::solver::SolvedProblem],
1184        is_dev_extraction: bool,
1185    ) -> String {
1186        let mut ctx = crate::problem::MessageContext {
1187            arena: &mut self.arena,
1188            set: &self.set,
1189            pool,
1190            request: &self.request,
1191            is_verbose: vivacity_core::io::shows(vivacity_core::io::Verbosity::Verbose),
1192            ini_files: &self.ini_files,
1193            loaded_extensions: &self.loaded_extensions,
1194        };
1195        crate::problem::pretty_string(&mut ctx, problems, is_dev_extraction)
1196    }
1197
1198    /// `createPool` with the PoolOptimizer (unless `COMPOSER_POOL_OPTIMIZER=0`),
1199    /// like `Installer::doUpdate`.
1200    /// `RepositorySet::findPackages($name)` on the `CompositeRepository` of
1201    /// `require` (platform then project repositories, all merged) with a
1202    /// `RepositorySet` reduced to `minimum-stability` (no flags);
1203    /// `ignore_stability` means `ALLOW_UNACCEPTABLE_STABILITIES`.
1204    pub fn find_packages_for_require(
1205        &mut self,
1206        name: &str,
1207        ignore_stability: bool,
1208    ) -> Result<Vec<usize>, SessionError> {
1209        let name = name.to_lowercase();
1210        let mut acceptable = BTreeMap::new();
1211        let min = crate::version::stability_rank(&self.root.minimum_stability);
1212        for st in ["stable", "RC", "beta", "alpha", "dev"] {
1213            let rank = crate::version::stability_rank(st);
1214            if ignore_stability || rank <= min {
1215                acceptable.insert(st.to_owned(), rank);
1216            }
1217        }
1218        let flags = BTreeMap::new();
1219        let already = BTreeMap::new();
1220        let map = vec![(name.clone(), Constraint::MatchAll)];
1221        let mut found: Vec<usize> = Vec::new();
1222        let (_, ids) = crate::pool::array_repository_load_packages(
1223            &self.platform,
1224            &map,
1225            &acceptable,
1226            &flags,
1227            &already,
1228            &self.arena,
1229        );
1230        found.extend(ids);
1231        // `PackageDiscoveryTrait::getRepos`: the platform, then the
1232        // manifest's repositories in order — `path` ones included, read as
1233        // the array repositories they are.
1234        for (i, repo) in self.set.repositories.iter().enumerate() {
1235            match repo {
1236                Repository::Composer(repo) => {
1237                    let (_, ids) = repo
1238                        .load_packages(
1239                            &map,
1240                            &acceptable,
1241                            &flags,
1242                            &already,
1243                            Origin::Repository(i),
1244                            &mut self.arena,
1245                        )
1246                        .map_err(SessionError::from)?;
1247                    found.extend(ids);
1248                }
1249                Repository::Path(repo) => {
1250                    let (_, ids) = crate::pool::array_repository_load_packages(
1251                        &repo.members,
1252                        &map,
1253                        &acceptable,
1254                        &flags,
1255                        &already,
1256                        &self.arena,
1257                    );
1258                    found.extend(ids);
1259                }
1260                Repository::Root(_) | Repository::Platform(_) | Repository::Locked(_) => {}
1261            }
1262        }
1263        Ok(found)
1264    }
1265
1266    pub fn create_optimized_pool(
1267        &mut self,
1268        policy: &mut DefaultPolicy,
1269    ) -> Result<Pool, SessionError> {
1270        let pool = self.create_filtered_pool()?;
1271        if std::env::var("COMPOSER_POOL_OPTIMIZER").as_deref() == Ok("0") {
1272            return Ok(pool);
1273        }
1274        Ok(PoolOptimizer::new().optimize(&self.request, &pool, &self.arena, policy))
1275    }
1276
1277    /// `buildPool` up to the policy filters (advisories, lists), without the
1278    /// optimizer; their warnings go into `pool.warnings`.
1279    pub fn create_filtered_pool(&mut self) -> Result<Pool, SessionError> {
1280        let mut pool = self.create_pool()?;
1281        let before = pool.len();
1282        // The policy filters rebuild the pool: what PRE_POOL_CREATE noted
1283        // rides along.
1284        let flex_notice = pool.flex_notice.take();
1285        let mut warnings = Vec::new();
1286        pool = crate::pool_filters::security_advisory_filter(
1287            pool,
1288            &self.arena,
1289            &self.set.repositories,
1290            &self.request,
1291            &self.policy_config,
1292            &mut warnings,
1293        )
1294        .map_err(SessionError::from)?;
1295        pool = crate::pool_filters::filter_list_filter(
1296            pool,
1297            &self.arena,
1298            &self.set.repositories,
1299            &self.request,
1300            &self.policy_config,
1301            "update",
1302            &mut warnings,
1303        )
1304        .map_err(SessionError::from)?;
1305        pool.warnings.extend(warnings);
1306        pool.flex_notice = flex_notice;
1307        if std::env::var_os("VIVACITY_TRACE").is_some() {
1308            eprintln!(
1309                "trace: policy filters      {before} → {} package versions ({} removed by lists)",
1310                pool.len(),
1311                pool.filter_list_removed
1312                    .values()
1313                    .map(Vec::len)
1314                    .sum::<usize>()
1315            );
1316        }
1317        Ok(pool)
1318    }
1319
1320    /// `Solver::solve` on this pool; returns the transaction and the
1321    /// decisions (pool literals, in order) with the rule set size.
1322    pub fn solve(
1323        &self,
1324        pool: &Pool,
1325        policy: &mut DefaultPolicy,
1326        filter: &PlatformRequirementFilter,
1327    ) -> Result<SolveReport, SolveError> {
1328        let mut solver = Solver::new(pool, &self.arena);
1329        let transaction = solver.solve(&self.request, policy, filter)?;
1330        let decisions = solver.decisions.queue.iter().map(|d| d.literal).collect();
1331        Ok(SolveReport {
1332            learned: solver
1333                .rules
1334                .ids_of_type(crate::rule::RuleType::Learned)
1335                .len(),
1336            rules: solver.rule_set_size(),
1337            decisions,
1338            transaction,
1339            events: Vec::new(),
1340        })
1341    }
1342}
1343
1344/// `Solver.php:231`: `Dependency resolution completed in %.3f seconds` at
1345/// VERBOSE, after every resolution, before its problems are thrown.
1346fn resolution_completed(before: std::time::Instant) -> (vivacity_core::io::Verbosity, String) {
1347    (
1348        vivacity_core::io::Verbosity::Verbose,
1349        format!(
1350            "Dependency resolution completed in {:.3} seconds",
1351            before.elapsed().as_secs_f64()
1352        ),
1353    )
1354}
1355
1356/// A session event, written at its level.
1357fn say_event((level, line): &(vivacity_core::io::Verbosity, String)) {
1358    vivacity_core::say_at!(*level, "{line}");
1359}
1360
1361/// The held lines, in order — on the error paths, where the command never
1362/// gets a report to print them from.
1363fn say_events(events: &[(vivacity_core::io::Verbosity, String)]) {
1364    events.iter().for_each(say_event);
1365}
1366
1367/// What `verify_install` reads, gathered by `install` before it starts the
1368/// verification on its own thread.
1369pub struct InstallVerifyInput {
1370    pub project_dir: std::path::PathBuf,
1371    pub composer_home: Option<std::path::PathBuf>,
1372    pub http: Option<HttpTransports>,
1373    pub cache_repo_dir: Option<std::path::PathBuf>,
1374    /// composer.json as written: the merged config (repositories, `policy`)
1375    /// the list filter reads.
1376    pub manifest_text: String,
1377    /// The root package Composer works with (merged by composer-merge-plugin
1378    /// when it is active): its platform requirements and the fixed root.
1379    pub root_manifest: Value,
1380    /// `Installer::$devMode`.
1381    pub dev_mode: bool,
1382    /// `--no-blocking` / `--no-security-blocking`.
1383    pub no_blocking: bool,
1384    /// `--ignore-platform-reqs`.
1385    pub ignore_platform_reqs: bool,
1386    /// `--ignore-platform-req`.
1387    pub ignore_platform_req: Vec<String>,
1388    /// composer-merge-plugin's `prependRepositories`: the repositories its
1389    /// merged files declare, at the head of the manager — the list filter
1390    /// asks them too.
1391    pub prepended_repositories: Vec<Value>,
1392    /// Set by `install` when it stops before the join (missing
1393    /// requirements, a hand-over): the thread then skips what is left after
1394    /// its network wait — in a program that embeds vivacity it would
1395    /// otherwise keep probing and solving after the command returned.
1396    pub abandoned: std::sync::Arc<std::sync::atomic::AtomicBool>,
1397}
1398
1399/// What `Installer::doInstall` concluded about the lock.
1400#[derive(Debug, PartialEq, Eq)]
1401pub enum InstallVerdict {
1402    /// The solve kept the lock as it is.
1403    Installable,
1404    /// `SolverProblemsException::getPrettyString`.
1405    Problems(String),
1406    /// The solve succeeded with operations: the lock cannot be installed
1407    /// without changes.
1408    NeedsChanges,
1409    /// A kept package failed `ValidatingArrayLoader::validatePackage`
1410    /// (`LockTransaction`, inside the solve): the exception's message.
1411    Invalid(String),
1412}
1413
1414#[derive(Debug)]
1415pub struct InstallVerification {
1416    /// The list filter's warnings (unreachable sources ignored per policy).
1417    pub warnings: Vec<String>,
1418    /// php could not be probed: the lock was solved without php, its
1419    /// extensions and libraries (their requirements ignored), the
1420    /// `composer-*` packages kept.
1421    pub php_missing: bool,
1422    pub verdict: InstallVerdict,
1423}
1424
1425/// The lock's settings `Installer` reads when it does not update
1426/// (`createRepositorySet(false)`, `createPolicy(false)`,
1427/// `createPlatformRepo(false)`).
1428struct LockSettings {
1429    minimum_stability: String,
1430    stability_flags: BTreeMap<String, i32>,
1431    /// `null` for a lock written before Composer recorded them: the root's
1432    /// `prefer-stable` then, and `--prefer-lowest` (which `install` does not
1433    /// have).
1434    prefer_stable: Option<bool>,
1435    prefer_lowest: Option<bool>,
1436}
1437
1438impl LockSettings {
1439    fn of(lock: &Value) -> LockSettings {
1440        // `$lockData[$key] ?? null`, then PHP's truthiness.
1441        let truthy = |key: &str| match lock.get(key)? {
1442            Value::Null => None,
1443            Value::Bool(b) => Some(*b),
1444            Value::Number(n) => Some(n.as_f64() != Some(0.0)),
1445            Value::String(s) => Some(!(s.is_empty() || s == "0")),
1446            Value::Array(a) => Some(!a.is_empty()),
1447            Value::Object(o) => Some(!o.is_empty()),
1448        };
1449        LockSettings {
1450            minimum_stability: lock
1451                .get("minimum-stability")
1452                .and_then(Value::as_str)
1453                .unwrap_or("stable")
1454                .to_owned(),
1455            stability_flags: lock
1456                .get("stability-flags")
1457                .and_then(Value::as_object)
1458                .map(|flags| {
1459                    flags
1460                        .iter()
1461                        .filter_map(|(n, v)| Some((n.clone(), i32::try_from(v.as_i64()?).ok()?)))
1462                        .collect()
1463                })
1464                .unwrap_or_default(),
1465            prefer_stable: truthy("prefer-stable"),
1466            prefer_lowest: truthy("prefer-lowest"),
1467        }
1468    }
1469}
1470
1471/// `$this->package->getRequires()`, plus `getDevRequires()` in dev mode:
1472/// `array_merge` keyed by target (a `require-dev` link replaces the
1473/// `require` one at its position).
1474fn root_requires(root: &Package, dev_mode: bool) -> crate::package::Links {
1475    let mut links = root.requires.clone();
1476    if dev_mode {
1477        for link in root.dev_requires.iter() {
1478            links.insert(link.clone());
1479        }
1480    }
1481    links
1482}
1483
1484/// `Installer::createRequest` and what `doInstall` adds to it before the
1485/// solve: the root (and its alias), the platform packages the root does not
1486/// provide at their version, every locked package fixed; then
1487/// `requireName` for the root's platform requirements, and for the lock's
1488/// (`platform`, plus `platform-dev` in dev mode) the root does not declare.
1489fn install_request(
1490    arena: &[Package],
1491    (fixed_root, fixed_root_alias): (usize, Option<usize>),
1492    platform: &[usize],
1493    locked: &[usize],
1494    root_links: &crate::package::Links,
1495    lock: &Value,
1496    dev_mode: bool,
1497) -> Result<Request, SessionError> {
1498    use crate::package::LinkType;
1499    let mut request = Request::new(Some(locked.to_vec()));
1500    if let Some(a) = fixed_root_alias {
1501        request.fix_package(a);
1502    }
1503    request.fix_package(fixed_root);
1504    for &p in platform {
1505        let provided_here = arena[fixed_root]
1506            .provides
1507            .get(&arena[p].name)
1508            .is_some_and(|l| {
1509                l.constraint
1510                    .matches(&Constraint::new(Op::Eq, arena[p].version.clone()))
1511            });
1512        if !provided_here {
1513            request.fix_package(p);
1514        }
1515    }
1516    for &idx in locked {
1517        request.fix_locked_package(idx);
1518    }
1519    for link in root_links.iter() {
1520        if crate::platform::is_platform_package(&link.target) {
1521            request.require_name_pretty(
1522                &link.target,
1523                Some(link.constraint.clone()),
1524                &link.pretty_constraint,
1525            )?;
1526        }
1527    }
1528    // `Locker::getPlatformRequirements`: `parseLinks('__root__', '1.0.0', …)`
1529    // of `platform`, `array_merge`d with `platform-dev` in dev mode.
1530    let parse = |key: &str| {
1531        crate::loader::parse_links("__root__", "1.0.0", LinkType::Require, lock.get(key), false)
1532            .map_err(|e| SessionError::new(format!("composer.lock: {}", e.0)))
1533    };
1534    let mut lock_links = parse("platform")?;
1535    if dev_mode {
1536        for link in parse("platform-dev")?.iter() {
1537            lock_links.insert(link.clone());
1538        }
1539    }
1540    for link in lock_links.iter() {
1541        if root_links.get(&link.target).is_none() {
1542            request.require_name_pretty(
1543                &link.target,
1544                Some(link.constraint.clone()),
1545                &link.pretty_constraint,
1546            )?;
1547        }
1548    }
1549    Ok(request)
1550}
1551
1552/// `Installer::createRepositorySet(false, …)`: the lock's stability, the
1553/// root's stability flag, the locked versions as root requirements
1554/// (`=` constraints, filtered by the platform requirement filter), and the
1555/// root, platform and lock repositories.
1556fn install_repository_set(
1557    arena: &[Package],
1558    root: &RootPackage,
1559    root_members: Vec<usize>,
1560    platform: &[usize],
1561    locked: &[usize],
1562    settings: &LockSettings,
1563    filter: &PlatformRequirementFilter,
1564) -> RepositorySet {
1565    let mut requires: OrderedMap<Constraint> = OrderedMap::default();
1566    for &idx in locked {
1567        let p = &arena[idx];
1568        if filter.is_ignored(&p.name) {
1569            continue;
1570        }
1571        let constraint = Constraint::new(Op::Eq, p.version.clone());
1572        let constraint = filter.filter_constraint(&p.name, &constraint, true);
1573        requires.insert(&p.name, constraint);
1574    }
1575    // `$stabilityFlags[$this->package->getName()]`: the version of the
1576    // root alias if there is one.
1577    let mut stability_flags = settings.stability_flags.clone();
1578    let root_version = match &root.branch_alias {
1579        Some((normalized, _)) => normalized.clone(),
1580        None => root.package.version.clone(),
1581    };
1582    stability_flags.insert(
1583        root.package.name.clone(),
1584        stability_rank(parse_stability(&root_version)),
1585    );
1586    let mut set = RepositorySet::new(
1587        &settings.minimum_stability,
1588        stability_flags,
1589        &[],
1590        root.references.clone(),
1591        requires,
1592        BTreeMap::new(),
1593    );
1594    set.add_repository(Repository::Root(root_members));
1595    set.add_repository(Repository::Platform(platform.to_vec()));
1596    set.add_repository(Repository::Locked(locked.to_vec()));
1597    set
1598}
1599
1600/// `createPlatformRepo(false)`: the probed platform under the overrides
1601/// recorded in the lock (`platform-overrides`), not the manifest's
1602/// `config.platform`.
1603fn locked_platform(probed: &[Value], lock: &Value) -> Result<Vec<Package>, SessionError> {
1604    let empty = Map::new();
1605    let overrides = lock
1606        .get("platform-overrides")
1607        .and_then(Value::as_object)
1608        .unwrap_or(&empty);
1609    platform_packages(probed, overrides).map_err(SessionError::from)
1610}
1611
1612/// The fixed root (`$fixedRootPackage`: requires emptied) and its alias,
1613/// pushed into the arena; the root repository's members, alias first.
1614fn push_fixed_root(arena: &mut Vec<Package>, root: &RootPackage) -> (usize, Option<usize>) {
1615    let mut fixed = root.package.clone();
1616    fixed.requires = Default::default();
1617    fixed.dev_requires = Default::default();
1618    arena.push(fixed);
1619    let fixed_root = arena.len() - 1;
1620    let alias = root.branch_alias.as_ref().map(|(normalized, pretty)| {
1621        let a = arena[fixed_root].alias(fixed_root, normalized, pretty);
1622        arena.push(a);
1623        arena.len() - 1
1624    });
1625    (fixed_root, alias)
1626}
1627
1628/// The locked pool's ingredients that do not depend on the root version:
1629/// the lock, its packages and the platform's, already in the arena.
1630struct LockedPool<'a> {
1631    lock: &'a Value,
1632    settings: LockSettings,
1633    locked: Vec<usize>,
1634    platform: Vec<usize>,
1635    dev_mode: bool,
1636    /// `--ignore-platform-req(s)` (`IgnoreAll` without php).
1637    filter: PlatformRequirementFilter,
1638}
1639
1640/// What the final solve needs from the list filter: the arena indices of
1641/// the locked packages it removed and their records.
1642type Filtered = (BTreeSet<usize>, crate::pool::FilterListRemoved);
1643
1644impl LockedPool<'_> {
1645    /// The repository set, the request and the pool (no optimizer, no
1646    /// filter yet) around `root`, pushed into the arena as the fixed root.
1647    fn build(
1648        &self,
1649        arena: &mut Vec<Package>,
1650        root: &RootPackage,
1651    ) -> Result<(RepositorySet, Request, Pool), SessionError> {
1652        let (fixed_root, alias) = push_fixed_root(arena, root);
1653        let members: Vec<usize> = alias.into_iter().chain([fixed_root]).collect();
1654        let set = install_repository_set(
1655            arena,
1656            root,
1657            members,
1658            &self.platform,
1659            &self.locked,
1660            &self.settings,
1661            &self.filter,
1662        );
1663        let mut request = install_request(
1664            arena,
1665            (fixed_root, alias),
1666            &self.platform,
1667            &self.locked,
1668            &root_requires(&root.package, self.dev_mode),
1669            self.lock,
1670            self.dev_mode,
1671        )?;
1672        let pool = set.create_pool(
1673            &mut request,
1674            arena,
1675            None,
1676            crate::pool::INSTALLER_IGNORED_TYPES,
1677        )?;
1678        Ok((set, request, pool))
1679    }
1680
1681    /// The pool around the real root, less what the list filter removed,
1682    /// then `Solver::solve` and, on failure, `getPrettyString`.
1683    fn solve(
1684        &self,
1685        arena: &mut Vec<Package>,
1686        root: &RootPackage,
1687        filtered: Option<Filtered>,
1688        probed: Option<&[Value]>,
1689    ) -> Result<InstallVerdict, SessionError> {
1690        let (set, request, mut pool) = self.build(arena, root)?;
1691        if let Some((removed, records)) = filtered {
1692            // The filter saw every locked package; the pool holds those the
1693            // lock's stability accepts. What it removed among them leaves,
1694            // with their records (`filterListRemovedVersions`) — and only
1695            // theirs: Composer never filtered the others.
1696            let removed_here: Vec<usize> = pool
1697                .packages
1698                .iter()
1699                .copied()
1700                .filter(|idx| removed.contains(idx))
1701                .collect();
1702            if !removed_here.is_empty() {
1703                let mut kept_records = crate::pool::FilterListRemoved::new();
1704                for &idx in &removed_here {
1705                    let p = &arena[idx];
1706                    for name in p.names(false) {
1707                        let Some(record) = records
1708                            .get(&name)
1709                            .and_then(|versions| versions.iter().find(|(v, _)| *v == p.version))
1710                        else {
1711                            continue;
1712                        };
1713                        let versions = kept_records.entry(name).or_default();
1714                        if !versions.iter().any(|(v, _)| *v == p.version) {
1715                            versions.push(record.clone());
1716                        }
1717                    }
1718                }
1719                let kept: Vec<usize> = pool
1720                    .packages
1721                    .iter()
1722                    .copied()
1723                    .filter(|idx| !removed.contains(idx))
1724                    .collect();
1725                pool = pool.with_packages(kept, arena);
1726                pool.filter_list_removed = kept_records;
1727            }
1728        }
1729        // `createPolicy(false)`: the lock's preferences, the root's
1730        // `prefer-stable` for a lock without them.
1731        let mut policy = DefaultPolicy::new(
1732            self.settings.prefer_stable.unwrap_or(root.prefer_stable),
1733            self.settings.prefer_lowest.unwrap_or(false),
1734            None,
1735        );
1736        let solved = Solver::new(&pool, arena).solve(&request, &mut policy, &self.filter);
1737        match solved {
1738            Ok(transaction) => {
1739                // `LockTransaction::setResultPackages` validates every kept
1740                // package, as in `update`.
1741                for &idx in &transaction.all {
1742                    if let Err(e) = crate::lockfile::validate_package(&arena[idx]) {
1743                        return Ok(InstallVerdict::Invalid(e));
1744                    }
1745                }
1746                Ok(if transaction.transaction.operations.is_empty() {
1747                    InstallVerdict::Installable
1748                } else {
1749                    InstallVerdict::NeedsChanges
1750                })
1751            }
1752            Err(SolveError::Problems(problems)) => {
1753                let ini_files = probed.map(crate::platform::ini_files).unwrap_or_default();
1754                let loaded_extensions = probed
1755                    .map(crate::platform::loaded_extensions)
1756                    .unwrap_or_default();
1757                let mut ctx = crate::problem::MessageContext {
1758                    arena,
1759                    set: &set,
1760                    pool: &pool,
1761                    request: &request,
1762                    is_verbose: vivacity_core::io::shows(vivacity_core::io::Verbosity::Verbose),
1763                    ini_files: &ini_files,
1764                    loaded_extensions: &loaded_extensions,
1765                };
1766                Ok(InstallVerdict::Problems(crate::problem::pretty_string(
1767                    &mut ctx, &problems, false,
1768                )))
1769            }
1770            Err(SolveError::Bug(b)) => Err(SessionError::new(b)),
1771        }
1772    }
1773}
1774
1775/// `Installer::doInstall` before it lays out a lock it did not just solve:
1776/// the request of `install_request`, the pool on [root, platform, lock]
1777/// through the filter list filter in `install` scope, then
1778/// `Solver::solve` under the `--ignore-platform-req(s)` filter. One solve,
1779/// one list of problems: the root's platform requirements, the lock's,
1780/// the conflicts of the fixed root and the locked versions a list blocks.
1781///
1782/// Runs on its own thread while `install` reads the local repository, in
1783/// the order that keeps the network wait first: the list filter runs on
1784/// the locked packages alone (it never judges the root or the platform),
1785/// then the platform is probed and the root version — detected by the
1786/// command for its missing requirements, git runs once — received; the
1787/// pool built around them loses what the filter removed.
1788pub fn verify_install(
1789    input: InstallVerifyInput,
1790    root_version: std::sync::mpsc::Receiver<vivacity_core::root_version::RootVersion>,
1791) -> Result<InstallVerification, SessionError> {
1792    let manifest: Value = serde_json::from_str(&input.manifest_text)
1793        .map_err(|e| SessionError::new(format!("composer.json: {e}")))?;
1794    let config = MergedConfig::load(&manifest, input.composer_home.as_deref())?;
1795    let mut policy =
1796        crate::policy_config::PolicyConfig::from_raw(&config.policy).map_err(SessionError::from)?;
1797    policy
1798        .apply_no_blocking(input.no_blocking)
1799        .map_err(SessionError::from)?;
1800    let lock_text = std::fs::read_to_string(input.project_dir.join("composer.lock"))
1801        .map_err(|e| SessionError::new(format!("composer.lock: {e}")))?;
1802    let lock: Value = serde_json::from_str(&lock_text)
1803        .map_err(|e| SessionError::new(format!("composer.lock: {e}")))?;
1804
1805    let mut arena: Vec<Package> = Vec::new();
1806    let locked = crate::repository::locked_repository_with(&lock, &mut arena, input.dev_mode)
1807        .map_err(SessionError::from)?;
1808
1809    let mut warnings = Vec::new();
1810    let filtered: Option<Filtered> = if policy.malware_blocks("install") {
1811        // Only `composer` repositories carry lists; the other types (which
1812        // `install` otherwise accepts) are left aside here.
1813        let mut repositories: Vec<Repository> = Vec::new();
1814        let prepended: Vec<RepoConfig> = input
1815            .prepended_repositories
1816            .iter()
1817            .enumerate()
1818            .map(|(i, definition)| RepoConfig {
1819                key: RepoKey::Indexed(i as u64),
1820                definition: definition.clone(),
1821            })
1822            .collect();
1823        for repo in prepended.iter().chain(&config.repositories) {
1824            if repo.definition.get("type").and_then(Value::as_str) != Some("composer") {
1825                continue;
1826            }
1827            // The constructor does no I/O: an error here is a configuration
1828            // error, fatal as in Composer.
1829            let mut opened = open_repository_with(
1830                repo,
1831                input.http.as_ref(),
1832                input.cache_repo_dir.as_deref(),
1833                true,
1834            )?;
1835            if let Repository::Composer(r) = &mut opened {
1836                r.hold_output();
1837            }
1838            repositories.push(opened);
1839        }
1840        let pool = Pool::new(locked.clone(), Vec::new(), &arena);
1841        let before = pool.packages.clone();
1842        // (In `install` scope the filter does not read the request.)
1843        let request = Request::new(Some(locked.clone()));
1844        let mut filter_warnings = Vec::new();
1845        let pool = match crate::pool_filters::filter_list_filter(
1846            pool,
1847            &arena,
1848            &repositories,
1849            &request,
1850            &policy,
1851            "install",
1852            &mut filter_warnings,
1853        ) {
1854            Ok(pool) => pool,
1855            // What the repositories wrote while loading was written before
1856            // the exception (a degraded repository, then another that
1857            // fails): kept with the error.
1858            Err(e) => {
1859                let mut e = SessionError::from(e);
1860                let mut preface = Vec::new();
1861                for repo in &repositories {
1862                    if let Repository::Composer(r) = repo {
1863                        preface.extend(r.take_held());
1864                    }
1865                }
1866                e.preface = preface.into_boxed_slice();
1867                return Err(e);
1868            }
1869        };
1870        // What the repositories wrote while loading came first.
1871        for repo in &repositories {
1872            if let Repository::Composer(r) = repo {
1873                warnings.extend(r.take_held());
1874            }
1875        }
1876        warnings.extend(filter_warnings);
1877        let removed: BTreeSet<usize> = before
1878            .into_iter()
1879            .filter(|idx| pool.id_of(*idx).is_none())
1880            .collect();
1881        Some((removed, pool.filter_list_removed))
1882    } else {
1883        None
1884    };
1885    let abandoned = || {
1886        if input.abandoned.load(std::sync::atomic::Ordering::Relaxed) {
1887            Err(SessionError::new("the lock verification was abandoned"))
1888        } else {
1889            Ok(())
1890        }
1891    };
1892    abandoned()?;
1893
1894    // `createPlatformRepo(false)`. Without php (no reference: Composer
1895    // cannot run), the `composer-*` packages and the lock's overrides only,
1896    // and the requirements on php, its extensions and libraries ignored.
1897    let probed = probe().ok();
1898    let packages = match &probed {
1899        Some(probed) => locked_platform(probed, &lock)?,
1900        None => locked_platform(&[], &lock)?,
1901    };
1902    let mut platform: Vec<usize> = Vec::new();
1903    for p in packages {
1904        arena.push(p);
1905        platform.push(arena.len() - 1);
1906    }
1907    let filter = if input.ignore_platform_reqs {
1908        PlatformRequirementFilter::IgnoreAll
1909    } else if probed.is_none() {
1910        let mut list = input.ignore_platform_req.clone();
1911        list.extend(["php", "php-*", "hhvm", "ext-*", "lib-*"].map(str::to_owned));
1912        PlatformRequirementFilter::from_list(&list)
1913    } else if !input.ignore_platform_req.is_empty() {
1914        PlatformRequirementFilter::from_list(&input.ignore_platform_req)
1915    } else {
1916        PlatformRequirementFilter::IgnoreNothing
1917    };
1918    let locked_pool = LockedPool {
1919        lock: &lock,
1920        settings: LockSettings::of(&lock),
1921        locked,
1922        platform,
1923        dev_mode: input.dev_mode,
1924        filter,
1925    };
1926    let rv = root_version
1927        .recv()
1928        .map_err(|_| SessionError::new("the root version was not detected"))?;
1929    let root = RootPackage::load_with_version(&input.root_manifest, rv)
1930        .map_err(|e| SessionError::new(e.0))?;
1931    abandoned()?;
1932    let verdict = locked_pool.solve(&mut arena, &root, filtered, probed.as_deref())?;
1933    Ok(InstallVerification {
1934        warnings,
1935        php_missing: probed.is_none(),
1936        verdict,
1937    })
1938}
1939
1940/// `RepositoryManager::createRepository` for the `composer` and `path`
1941/// types (`vcs` and the others are not supported yet). A `path` repository
1942/// reads its packages now, into the arena, as `origin`.
1943fn open_repository(
1944    repo: &RepoConfig,
1945    http: Option<&HttpTransports>,
1946    cache_repo_dir: Option<&Path>,
1947    project_dir: &Path,
1948    origin: Origin,
1949    arena: &mut Vec<Package>,
1950) -> Result<Repository, SessionError> {
1951    if repo.definition.get("type").and_then(Value::as_str) == Some("path") {
1952        if repo.definition.get("only").is_some()
1953            || repo.definition.get("exclude").is_some()
1954            || repo.definition.get("canonical").is_some()
1955        {
1956            return Err(SessionError::new(format!(
1957                "repository filters (only/exclude/canonical) are not supported by vivacity update yet ({})",
1958                key_string(&repo.key)
1959            )));
1960        }
1961        let path_repo = crate::path_repo::open(&repo.definition, project_dir, origin, arena)
1962            .map_err(SessionError::from)?;
1963        return Ok(Repository::Path(path_repo));
1964    }
1965    open_repository_with(repo, http, cache_repo_dir, false)
1966}
1967
1968/// `for_policies`: a repository with `only`/`exclude`/`canonical`
1969/// (`FilterRepository`) is accepted (the advisory and list paths honor
1970/// `only`/`exclude`) where resolution still rejects it.
1971fn open_repository_with(
1972    repo: &RepoConfig,
1973    http: Option<&HttpTransports>,
1974    cache_repo_dir: Option<&Path>,
1975    for_policies: bool,
1976) -> Result<Repository, SessionError> {
1977    let def = &repo.definition;
1978    let kind = def.get("type").and_then(Value::as_str).ok_or_else(|| {
1979        SessionError::new(format!(
1980            "Repository \"{}\" ({def}) must have a type defined",
1981            key_string(&repo.key)
1982        ))
1983    })?;
1984    if kind != "composer" {
1985        return Err(SessionError::new(format!(
1986            "repository type \"{kind}\" is not supported by vivacity update yet ({})",
1987            key_string(&repo.key)
1988        )));
1989    }
1990    if !for_policies
1991        && (def.get("only").is_some()
1992            || def.get("exclude").is_some()
1993            || def.get("canonical").is_some())
1994    {
1995        return Err(SessionError::new(format!(
1996            "repository filters (only/exclude/canonical) are not supported by vivacity update yet ({})",
1997            key_string(&repo.key)
1998        )));
1999    }
2000    let url = def.get("url").and_then(Value::as_str).ok_or_else(|| {
2001        SessionError::new(format!("repository {} has no url", key_string(&repo.key)))
2002    })?;
2003    let transport: Box<dyn crate::repository::Transport> = if url.starts_with("file://") {
2004        Box::new(FileTransport)
2005    } else if url.starts_with("http://") || url.starts_with("https://") || !url.contains("://") {
2006        match http {
2007            Some(h) => Box::new(HttpTransport {
2008                fetch: h.0.clone(),
2009                fetch_many: h.1.clone(),
2010                post: h.2.clone(),
2011            }),
2012            None => {
2013                return Err(SessionError::new(format!(
2014                    "remote composer repositories need a network transport ({url})"
2015                )))
2016            }
2017        }
2018    } else {
2019        return Err(SessionError::new(format!(
2020            "unsupported repository url scheme ({url})"
2021        )));
2022    };
2023    let mut repo = ComposerRepository::open(url, transport).map_err(SessionError::from)?;
2024    if let Some(options) = def.get("options") {
2025        repo.options = options.clone();
2026    }
2027    repo.set_user_filter(def.get("filter"))
2028        .map_err(SessionError::from)?;
2029    if for_policies {
2030        repo.set_name_filter(def.get("only"), def.get("exclude"))
2031            .map_err(SessionError::from)?;
2032    }
2033    if let Some(dir) = cache_repo_dir {
2034        repo.cache = Some(crate::metacache::MetadataCache::new(dir, &repo.url));
2035    }
2036    Ok(Repository::Composer(Box::new(repo)))
2037}
2038
2039fn key_string(key: &RepoKey) -> String {
2040    match key {
2041        RepoKey::Named(n) => n.clone(),
2042        RepoKey::Indexed(i) => i.to_string(),
2043    }
2044}
2045
2046#[cfg(test)]
2047mod tests {
2048    use super::*;
2049    use serde_json::json;
2050
2051    fn names(repos: &[RepoConfig]) -> Vec<String> {
2052        repos.iter().map(|r| key_string(&r.key)).collect()
2053    }
2054
2055    #[test]
2056    fn merges_repositories_like_composer_config() {
2057        let mut cfg = MergedConfig::load(&json!({}), None).unwrap();
2058        assert_eq!(names(&cfg.repositories), vec!["packagist.org"]);
2059        // global config: snapshot + packagist disabled.
2060        cfg.merge(&json!({"repositories": {"snapshot": {"type": "composer", "url": "file:///s"}, "packagist.org": false}}));
2061        assert_eq!(names(&cfg.repositories), vec!["snapshot"]);
2062        // composer.json: an indexed repository goes first.
2063        cfg.merge(&json!({"repositories": [{"type": "composer", "url": "https://packages.drupal.org/8"}]}));
2064        assert_eq!(names(&cfg.repositories), vec!["0", "snapshot"]);
2065        // two indexed ones while 0 exists: 1 takes its key, 0 is renumbered
2066        // (`$this->repositories[] = ...`); the new ones stay in front.
2067        cfg.merge(
2068            &json!({"repositories": [{"type": "vcs", "url": "a"}, {"type": "vcs", "url": "b"}]}),
2069        );
2070        assert_eq!(names(&cfg.repositories), vec!["2", "1", "0", "snapshot"]);
2071        assert_eq!(cfg.repositories[0].definition["url"], "a");
2072        assert_eq!(cfg.repositories[1].definition["url"], "b");
2073        assert_eq!(
2074            cfg.repositories[2].definition["url"],
2075            "https://packages.drupal.org/8"
2076        );
2077    }
2078
2079    #[test]
2080    fn packagist_url_disables_default() {
2081        let mut cfg = MergedConfig::load(&json!({}), None).unwrap();
2082        cfg.merge(
2083            &json!({"repositories": [{"type": "composer", "url": "https://repo.packagist.org"}]}),
2084        );
2085        assert_eq!(names(&cfg.repositories), vec!["0"]);
2086        let mut cfg = MergedConfig::load(&json!({}), None).unwrap();
2087        cfg.merge(&json!({"repositories": [{"packagist": false}]}));
2088        assert!(cfg.repositories.is_empty());
2089    }
2090
2091    // -- install verification ---------------------------------------------
2092
2093    /// A probed PHP 8.2.5 with mbstring and intl.
2094    fn probed() -> Vec<Value> {
2095        vec![
2096            json!({"kind": "php", "name": "php", "version": "8.2.5", "description": "The PHP interpreter"}),
2097            json!({"kind": "ext", "name": "mbstring", "version": "8.2.5"}),
2098            json!({"kind": "ext", "name": "intl", "version": "8.2.5"}),
2099        ]
2100    }
2101
2102    fn root_version() -> vivacity_core::root_version::RootVersion {
2103        vivacity_core::root_version::RootVersion {
2104            pretty_version: "1.0.0+no-version-set".to_owned(),
2105            version: "1.0.0.0".to_owned(),
2106            reference: None,
2107        }
2108    }
2109
2110    /// `verify_install` without the probe, the network or the thread.
2111    fn verify(
2112        manifest: Value,
2113        lock: Value,
2114        dev_mode: bool,
2115        filter: PlatformRequirementFilter,
2116    ) -> (InstallVerdict, Request) {
2117        verify_flagged(manifest, lock, dev_mode, filter, &[])
2118    }
2119
2120    /// `verify`, with the locked packages named in `flagged` removed by the
2121    /// list filter (a malware entry each).
2122    fn verify_flagged(
2123        manifest: Value,
2124        lock: Value,
2125        dev_mode: bool,
2126        filter: PlatformRequirementFilter,
2127        flagged: &[&str],
2128    ) -> (InstallVerdict, Request) {
2129        let mut arena = Vec::new();
2130        let locked = crate::repository::locked_repository_with(&lock, &mut arena, dev_mode)
2131            .expect("locked repository");
2132        let mut platform = Vec::new();
2133        for p in locked_platform(&probed(), &lock).expect("platform") {
2134            arena.push(p);
2135            platform.push(arena.len() - 1);
2136        }
2137        let pool = LockedPool {
2138            lock: &lock,
2139            settings: LockSettings::of(&lock),
2140            locked,
2141            platform,
2142            dev_mode,
2143            filter,
2144        };
2145        let root = RootPackage::load_with_version(&manifest, root_version()).expect("root");
2146        let (_, request, _) = pool.build(&mut arena.clone(), &root).expect("build");
2147        let filtered = (!flagged.is_empty()).then(|| {
2148            let mut removed = BTreeSet::new();
2149            let mut records = crate::pool::FilterListRemoved::new();
2150            for &idx in &pool.locked {
2151                let p = &arena[idx];
2152                if !flagged.contains(&p.name.as_str()) {
2153                    continue;
2154                }
2155                removed.insert(idx);
2156                let entry = crate::repository::FilterEntry {
2157                    package_name: p.name.clone(),
2158                    constraint: Constraint::MatchAll,
2159                    list_name: "malware".to_owned(),
2160                    url: None,
2161                    reason: None,
2162                    id: None,
2163                    source: Some("testsource".to_owned()),
2164                };
2165                records
2166                    .entry(p.name.clone())
2167                    .or_default()
2168                    .push((p.version.clone(), vec![entry]));
2169            }
2170            (removed, records)
2171        });
2172        let verdict = pool
2173            .solve(&mut arena, &root, filtered, Some(&probed()))
2174            .expect("solve");
2175        (verdict, request)
2176    }
2177
2178    fn requires(request: &Request) -> Vec<(String, String)> {
2179        request
2180            .pretty_requires
2181            .iter()
2182            .map(|(n, p)| (n.clone(), p.clone()))
2183            .collect()
2184    }
2185
2186    fn lock_with(packages: Value, dev: Value, platform: Value, platform_dev: Value) -> Value {
2187        json!({
2188            "packages": packages, "packages-dev": dev, "aliases": [],
2189            "minimum-stability": "stable", "stability-flags": {},
2190            "prefer-stable": false, "prefer-lowest": false,
2191            "platform": platform, "platform-dev": platform_dev,
2192        })
2193    }
2194
2195    fn pair(n: &str, p: &str) -> (String, String) {
2196        (n.to_owned(), p.to_owned())
2197    }
2198
2199    #[test]
2200    fn requires_the_roots_platform_requirements_then_the_locks_it_does_not_declare() {
2201        let manifest = json!({
2202            "name": "acme/root",
2203            "require": {"php": "^8.0", "ext-intl": "*", "acme/lib": "^1.0"},
2204            "require-dev": {"ext-intl": ">=1", "ext-mbstring": "*"},
2205        });
2206        let lock = lock_with(
2207            json!([]),
2208            json!([]),
2209            json!({"php": ">=8.3", "ext-mbstring": "^8", "ext-json": "*"}),
2210            json!({"ext-intl": "^9", "ext-zip": "*"}),
2211        );
2212        // Dev mode: `array_merge(require, require-dev)` — the `require-dev`
2213        // constraint at the `require` position; the lock's `php`,
2214        // `ext-mbstring` and `ext-intl` are the root's already.
2215        let (_, request) = verify(
2216            manifest.clone(),
2217            lock.clone(),
2218            true,
2219            PlatformRequirementFilter::IgnoreNothing,
2220        );
2221        assert_eq!(
2222            requires(&request),
2223            vec![
2224                pair("php", "^8.0"),
2225                pair("ext-intl", ">=1"),
2226                pair("ext-mbstring", "*"),
2227                pair("ext-json", "*"),
2228                pair("ext-zip", "*"),
2229            ]
2230        );
2231        // `--no-dev`: no `require-dev`, no `platform-dev`; the lock's
2232        // `ext-mbstring` is no longer the root's.
2233        let (_, request) = verify(
2234            manifest,
2235            lock,
2236            false,
2237            PlatformRequirementFilter::IgnoreNothing,
2238        );
2239        assert_eq!(
2240            requires(&request),
2241            vec![
2242                pair("php", "^8.0"),
2243                pair("ext-intl", "*"),
2244                pair("ext-mbstring", "^8"),
2245                pair("ext-json", "*"),
2246            ]
2247        );
2248    }
2249
2250    #[test]
2251    fn the_roots_constraint_wins_over_the_locks() {
2252        let lock = lock_with(json!([]), json!([]), json!({"php": "^99"}), json!({}));
2253        let (verdict, _) = verify(
2254            json!({"require": {"php": "^8.0"}}),
2255            lock.clone(),
2256            true,
2257            PlatformRequirementFilter::IgnoreNothing,
2258        );
2259        assert_eq!(verdict, InstallVerdict::Installable);
2260        let (verdict, _) = verify(
2261            json!({}),
2262            lock,
2263            true,
2264            PlatformRequirementFilter::IgnoreNothing,
2265        );
2266        let InstallVerdict::Problems(text) = verdict else {
2267            panic!("{verdict:?}")
2268        };
2269        assert!(
2270            text.contains("Root composer.json requires php ^99 but your php version (8.2.5)"),
2271            "{text}"
2272        );
2273    }
2274
2275    #[test]
2276    fn locked_packages_platform_requirements_and_the_ignore_filter() {
2277        let lock = lock_with(
2278            json!([{"name": "acme/a", "version": "1.0.0", "type": "library", "require": {"ext-gd": "*"}}]),
2279            json!([]),
2280            json!({}),
2281            json!({}),
2282        );
2283        let (verdict, _) = verify(
2284            json!({"require": {"acme/a": "^1.0"}}),
2285            lock.clone(),
2286            true,
2287            PlatformRequirementFilter::IgnoreNothing,
2288        );
2289        let InstallVerdict::Problems(text) = verdict else {
2290            panic!("{verdict:?}")
2291        };
2292        assert!(
2293            text.contains("acme/a 1.0.0 requires ext-gd * -> it is missing from your system."),
2294            "{text}"
2295        );
2296        for ignored in [
2297            PlatformRequirementFilter::IgnoreAll,
2298            PlatformRequirementFilter::from_list(&["ext-gd".to_owned()]),
2299            PlatformRequirementFilter::from_list(&["ext-*".to_owned()]),
2300        ] {
2301            let (verdict, _) = verify(
2302                json!({"require": {"acme/a": "^1.0"}}),
2303                lock.clone(),
2304                true,
2305                ignored,
2306            );
2307            assert_eq!(verdict, InstallVerdict::Installable);
2308        }
2309    }
2310
2311    #[test]
2312    fn platform_dev_only_in_dev_mode() {
2313        let lock = lock_with(
2314            json!([]),
2315            json!([{"name": "acme/dev", "version": "1.0.0", "type": "library", "require": {"ext-zip": "*"}}]),
2316            json!({}),
2317            json!({"ext-zip": "*"}),
2318        );
2319        let (verdict, _) = verify(
2320            json!({}),
2321            lock.clone(),
2322            false,
2323            PlatformRequirementFilter::IgnoreNothing,
2324        );
2325        assert_eq!(verdict, InstallVerdict::Installable);
2326        let (verdict, _) = verify(
2327            json!({}),
2328            lock,
2329            true,
2330            PlatformRequirementFilter::IgnoreNothing,
2331        );
2332        assert!(
2333            matches!(verdict, InstallVerdict::Problems(_)),
2334            "{verdict:?}"
2335        );
2336    }
2337
2338    #[test]
2339    fn the_locks_platform_overrides_apply() {
2340        let mut lock = lock_with(
2341            json!([]),
2342            json!([]),
2343            json!({"php": ">=8.3", "ext-gd": "*"}),
2344            json!({}),
2345        );
2346        let (verdict, _) = verify(
2347            json!({}),
2348            lock.clone(),
2349            true,
2350            PlatformRequirementFilter::IgnoreNothing,
2351        );
2352        assert!(
2353            matches!(verdict, InstallVerdict::Problems(_)),
2354            "{verdict:?}"
2355        );
2356        lock["platform-overrides"] = json!({"php": "8.3.0", "ext-gd": "2.0.0"});
2357        let (verdict, _) = verify(
2358            json!({}),
2359            lock,
2360            true,
2361            PlatformRequirementFilter::IgnoreNothing,
2362        );
2363        assert_eq!(verdict, InstallVerdict::Installable);
2364    }
2365
2366    #[test]
2367    fn the_fixed_root_keeps_its_conflicts() {
2368        let lock = lock_with(
2369            json!([{"name": "acme/a", "version": "1.0.0", "type": "library"}]),
2370            json!([]),
2371            json!({}),
2372            json!({}),
2373        );
2374        let (verdict, _) = verify(
2375            json!({"name": "acme/root", "require": {"acme/a": "*"}, "conflict": {"acme/a": "*"}}),
2376            lock,
2377            true,
2378            PlatformRequirementFilter::IgnoreNothing,
2379        );
2380        let InstallVerdict::Problems(text) = verdict else {
2381            panic!("{verdict:?}")
2382        };
2383        assert!(
2384            text.contains("acme/root 1.0.0+no-version-set conflicts with acme/a 1.0.0."),
2385            "{text}"
2386        );
2387    }
2388
2389    #[test]
2390    fn without_php_the_composer_packages_remain() {
2391        let names: Vec<String> = platform_packages(&[], &Map::new())
2392            .expect("platform")
2393            .into_iter()
2394            .map(|p| p.name)
2395            .collect();
2396        assert_eq!(
2397            names,
2398            ["composer", "composer-plugin-api", "composer-runtime-api"]
2399        );
2400    }
2401
2402    #[test]
2403    fn a_lock_without_preferences_leaves_them_to_the_root() {
2404        let mut lock = lock_with(json!([]), json!([]), json!({}), json!({}));
2405        assert_eq!(LockSettings::of(&lock).prefer_stable, Some(false));
2406        lock.as_object_mut().expect("lock").remove("prefer-stable");
2407        lock.as_object_mut().expect("lock").remove("prefer-lowest");
2408        let settings = LockSettings::of(&lock);
2409        assert_eq!(
2410            (settings.prefer_stable, settings.prefer_lowest),
2411            (None, None)
2412        );
2413    }
2414
2415    #[test]
2416    fn a_locked_package_the_locks_stability_rejects_needs_changes() {
2417        // Outside the pool (`unacceptableFixedOrLockedPackages`) but present:
2418        // the solve succeeds with its removal.
2419        let lock = lock_with(
2420            json!([{"name": "acme/dev", "version": "dev-main", "type": "library"}]),
2421            json!([]),
2422            json!({}),
2423            json!({}),
2424        );
2425        let (verdict, _) = verify(
2426            json!({"require": {"acme/dev": "dev-main"}}),
2427            lock.clone(),
2428            true,
2429            PlatformRequirementFilter::IgnoreNothing,
2430        );
2431        assert_eq!(verdict, InstallVerdict::NeedsChanges);
2432        let mut flagged = lock;
2433        flagged["stability-flags"] = json!({"acme/dev": 20});
2434        let (verdict, _) = verify(
2435            json!({"require": {"acme/dev": "dev-main"}}),
2436            flagged,
2437            true,
2438            PlatformRequirementFilter::IgnoreNothing,
2439        );
2440        assert_eq!(verdict, InstallVerdict::Installable);
2441    }
2442
2443    #[test]
2444    fn what_the_list_filter_removed_is_one_problem_among_the_others() {
2445        let lock = lock_with(
2446            json!([
2447                {"name": "acme/bad", "version": "1.1.0", "type": "library"},
2448                {"name": "acme/dev", "version": "dev-main", "type": "library"},
2449            ]),
2450            json!([]),
2451            json!({"ext-nope": "*"}),
2452            json!({}),
2453        );
2454        let (verdict, _) = verify_flagged(
2455            json!({}),
2456            lock,
2457            true,
2458            PlatformRequirementFilter::IgnoreNothing,
2459            &["acme/bad", "acme/dev"],
2460        );
2461        let InstallVerdict::Problems(text) = verdict else {
2462            panic!("{verdict:?}")
2463        };
2464        // The root requirement first, then the blocked package; `acme/dev`,
2465        // outside the pool (its stability), was never judged by the filter.
2466        let nope = text.find("requires PHP extension ext-nope").expect(&text);
2467        let bad = text
2468            .find("acme/bad 1.1.0 (in the lock file) was not loaded, because it was flagged as malware reported by testsource")
2469            .expect(&text);
2470        assert!(nope < bad, "{text}");
2471        assert!(!text.contains("acme/dev"), "{text}");
2472    }
2473}