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