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