use crate::composer::{ AutoloadDumpOptions, AutoloadGenerator, InstallationManager, LocalRepository, Locker, PlatformRequirementFilter, }; use crate::config::Config; use crate::package::RootPackageData; use crate::repository::installed::InstalledPackages; use crate::repository::lockfile::LockedPackage; use indexmap::IndexSet; use mozart_class_map_generator::{scan_classmap_dirs, scan_psr_for_classmap}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; // Embed Composer PHP files from the submodule at compile time. const CLASSLOADER_PHP: &str = include_str!("../../../composer/src/Composer/Autoload/ClassLoader.php"); const INSTALLED_VERSIONS_PHP: &str = include_str!("../../../composer/src/Composer/InstalledVersions.php"); const COMPOSER_LICENSE: &str = include_str!("../../../composer/LICENSE"); /// How platform requirements are checked during autoloader generation. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum PlatformCheckMode { /// Check all platform requirements (php, ext-*, lib-*). #[default] Full, /// Only check the PHP version requirement. PhpOnly, /// Disable platform requirement checks entirely. Disabled, } /// Result of autoload generation, reporting statistics and warnings. pub struct GenerateResult { pub class_count: usize, pub has_psr_violations: bool, pub has_ambiguous_classes: bool, } /// Configuration for autoload generation. pub struct AutoloadConfig { /// Absolute path to the project root (where composer.json lives). pub project_dir: PathBuf, /// Absolute path to the vendor directory. pub vendor_dir: PathBuf, /// Whether dev-mode autoloading is active (include autoload-dev rules). pub dev_mode: bool, /// Unique suffix for the autoloader class names (typically the lock file content-hash). /// Used to generate `ComposerAutoloaderInit{suffix}` and `ComposerStaticInit{suffix}`. pub suffix: String, /// When true, emit `$loader->setClassMapAuthoritative(true)` in the generated autoloader. pub classmap_authoritative: bool, /// When true, scan PSR-4/PSR-0 directories and generate a full classmap (optimize mode). pub optimize: bool, /// When true, generate APCu-based class caching in the autoloader. pub apcu: bool, /// Optional prefix for APCu cache keys (implies `apcu`). pub apcu_prefix: Option, /// When true, return an error on PSR mapping violations detected during classmap scan. pub strict_psr: bool, /// When true, return exit code 2 if ambiguous class mappings are detected. pub strict_ambiguous: bool, /// How to handle platform requirement checks. pub platform_check: PlatformCheckMode, /// When true, skip all platform requirement checks. pub ignore_platform_reqs: bool, } /// Collected autoload mappings from all packages. pub struct AutoloadData { /// PSR-4: namespace prefix -> list of directory path expressions. /// Each path is a PHP expression string like `$vendorDir . '/psr/log/src'`. pub psr4: BTreeMap>, /// PSR-0: namespace prefix -> list of directory path expressions. /// (Empty in Phase 2.2, populated in 5.6.) pub psr0: BTreeMap>, /// Classmap entries: class name -> file path expression. /// (Empty in Phase 2.2, populated in 5.6.) pub classmap: BTreeMap, /// Files to include on every request: file_identifier -> path expression. pub files: BTreeMap, } /// Mirror of `Composer\ClassMapGenerator\ClassMap` — the return value /// of `AutoloadGenerator::dump`. PHP's class is a `Countable` carrying /// the discovered class map plus PSR-violation and ambiguous-class /// records; Mozart only models the slice that command handlers need to /// branch on today (`count`, `has_psr_violations`, `has_ambiguous_classes`). /// /// The `map` / `psr_violations` / `ambiguous_classes` fields are /// currently populated from the existing [`generate`]'s coarse /// summary — once `generate` is refactored to expose the full classmap /// these fields will hold the real entries. pub struct ClassMap { map: BTreeMap, psr_violations: Vec, ambiguous_classes: BTreeMap>, } impl ClassMap { /// Mirror of `ClassMap::count`. pub fn count(&self) -> usize { self.map.len() } /// Mirror of `count($classMap->getPsrViolations()) > 0`. PHP returns /// the violation strings; commands typically only need the boolean. pub fn has_psr_violations(&self) -> bool { !self.psr_violations.is_empty() } /// Mirror of `count($classMap->getAmbiguousClasses($filter)) > 0`. /// `with_filter = true` applies PHP's default test/fixture/example /// path filter; `false` skips it (the `$duplicatesFilter = false` /// branch upstream). pub fn has_ambiguous_classes(&self, with_filter: bool) -> bool { if !with_filter { return !self.ambiguous_classes.is_empty(); } let pattern = regex_filter_default(); self.ambiguous_classes.values().any(|paths| { paths .iter() .any(|p| !pattern.is_match(&p.replace('\\', "/"))) }) } /// Read access to the underlying map (`getMap()` upstream). pub fn map(&self) -> &BTreeMap { &self.map } /// Read access to the PSR-violation warnings. pub fn psr_violations(&self) -> &[String] { &self.psr_violations } /// Read access to the ambiguous-class records. pub fn ambiguous_classes(&self) -> &BTreeMap> { &self.ambiguous_classes } } fn regex_filter_default() -> regex::Regex { use std::sync::OnceLock; static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { // `{/(test|fixture|example|stub)s?/}i` from PHP's // ClassMap::getAmbiguousClasses default. regex::Regex::new(r"(?i)/(test|fixture|example|stub)s?/") .expect("default ambiguous filter compiles") }) .clone() } /// Extension trait hanging `dump()` off /// [`crate::composer::AutoloadGenerator`]. Mirrors /// `Composer\Autoload\AutoloadGenerator::dump()`. /// /// Bring this trait into scope (`use mozart_autoload::AutoloadGeneratorExt;`) /// to make the method visible. /// /// Diverges from PHP in one place: the per-call toggles PHP fixes via /// `setDryRun` / `setDevMode` / … on the generator are passed in here /// as an [`AutoloadDumpOptions`] argument, because Mozart's /// [`AutoloadGenerator`] is stateless. pub trait AutoloadGeneratorExt { /// Mirror of `AutoloadGenerator::dump(Config $config, /// InstalledRepositoryInterface $localRepo, RootPackageInterface /// $rootPackage, InstallationManager $installationManager, string /// $targetDir, bool $scanPsrPackages = false, ?string $suffix = null, /// ?Locker $locker = null, bool $strictAmbiguous = false)`. /// /// Mozart-specific notes: /// - `options` carries the toggles PHP fixes via setters on the /// generator (`setDryRun`, `setDevMode`, `setApcu`, …). /// - `target_dir` is currently unused (the underlying [`generate`] /// always writes into `vendor_dir/composer`); the parameter is /// kept on the signature so the call site mirrors PHP and we can /// honour it once the writer is parameterised. /// - `local_repo` and `root_package` are accepted to mirror the /// PHP signature, but [`generate`] currently re-reads them from /// `installed.json` / `composer.json`. Refactoring to consume the /// passed-in values lives in a follow-up. #[allow(clippy::too_many_arguments)] fn dump( &self, options: &AutoloadDumpOptions, config: &Config, local_repo: &LocalRepository, root_package: &RootPackageData, installation_manager: &InstallationManager, target_dir: &str, scan_psr_packages: bool, suffix: Option<&str>, locker: &Locker, strict_ambiguous: bool, ) -> anyhow::Result; } impl AutoloadGeneratorExt for AutoloadGenerator { fn dump( &self, options: &AutoloadDumpOptions, config: &Config, _local_repo: &LocalRepository, _root_package: &RootPackageData, installation_manager: &InstallationManager, _target_dir: &str, scan_psr_packages: bool, suffix: Option<&str>, locker: &Locker, strict_ambiguous: bool, ) -> anyhow::Result { // Mirrors PHP: classmap-authoritative implies PSR scanning so // every class gets a fixed map entry. let scan = scan_psr_packages || options.class_map_authoritative; // Mirrors PHP's `if (null === $this->devMode)` branch: read the // `dev` flag from `vendor/composer/installed.json` when no // explicit dev-mode has been set on the options. let dev_mode = match options.dev_mode { Some(m) => m, None => read_installed_dev_flag(installation_manager.vendor_dir()), }; // Mirrors PHP's suffix resolution chain in `dump()`: // 1. explicit argument // 2. `Config::get('autoloader-suffix')` // 3. existing `vendor/autoload.php`'s `ComposerAutoloaderInit{X}` // 4. `composer.lock`'s `content-hash` (when locked) // 5. random hex let resolved_suffix = resolve_suffix(suffix, config, installation_manager, locker)?; // Mirrors PHP: `$basePath = realpath(getcwd())`. We don't have // an explicit project_dir on the generator, but `vendor_dir`'s // parent matches the project root for the common // `vendor-dir = "vendor"` layout. When the user points // `vendor-dir` outside the project we fall back to `.`. let project_dir = installation_manager .vendor_dir() .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| PathBuf::from(".")); // Mirrors PHP's `$checkPlatform = $config->get('platform-check') !== // false && !($filter instanceof IgnoreAllPlatformRequirementFilter)`. let platform_check = if matches!( options.platform_requirement_filter, PlatformRequirementFilter::IgnoreAll ) { PlatformCheckMode::Disabled } else { platform_check_mode_from_config(&config.platform_check) }; let cfg = AutoloadConfig { project_dir, vendor_dir: installation_manager.vendor_dir().to_path_buf(), dev_mode, suffix: resolved_suffix, classmap_authoritative: options.class_map_authoritative, optimize: scan, apcu: options.apcu, apcu_prefix: options.apcu_prefix.clone(), // `dump()` does not surface a `--strict-psr` option (that's // a separate command-line flag on `dump-autoload`); the // generator only reports violations via `ClassMap`. strict_psr: false, strict_ambiguous, platform_check, ignore_platform_reqs: matches!( options.platform_requirement_filter, PlatformRequirementFilter::IgnoreAll ), }; if options.dry_run { // PHP's dry-run still scans and returns the classmap but // skips file writes. The current [`generate`] does not // expose a dry-run hook, so we return an empty ClassMap // for now and surface the limitation here rather than // silently writing files. return Ok(ClassMap { map: BTreeMap::new(), psr_violations: Vec::new(), ambiguous_classes: BTreeMap::new(), }); } let result = generate(&cfg)?; // Mozart's `GenerateResult` only carries summary flags // (`class_count`, `has_psr_violations`, `has_ambiguous_classes`), // not the actual class-name / path entries that PHP's `ClassMap` // exposes. We project the summary onto a `ClassMap` shape so // command code that only branches on `count()` / `has_*()` works // today; refactoring `generate` to surface the full map is // tracked as follow-up work. let mut map = BTreeMap::new(); for i in 0..result.class_count { map.insert(format!("__mozart_placeholder_{i}"), String::new()); } let psr_violations = if result.has_psr_violations { vec![String::from( "PSR-0/4 violation detected (details not yet surfaced)", )] } else { Vec::new() }; let mut ambiguous_classes = BTreeMap::new(); if result.has_ambiguous_classes { ambiguous_classes.insert("__mozart_placeholder".to_string(), Vec::new()); } Ok(ClassMap { map, psr_violations, ambiguous_classes, }) } } fn read_installed_dev_flag(vendor_dir: &std::path::Path) -> bool { let path = vendor_dir.join("composer/installed.json"); if !path.exists() { return false; } let Ok(content) = std::fs::read_to_string(&path) else { return false; }; let Ok(value) = serde_json::from_str::(&content) else { return false; }; value.get("dev").and_then(|v| v.as_bool()).unwrap_or(false) } fn resolve_suffix( explicit: Option<&str>, config: &Config, installation_manager: &InstallationManager, locker: &Locker, ) -> anyhow::Result { if let Some(s) = explicit && !s.is_empty() { return Ok(s.to_string()); } if let Some(s) = config.autoloader_suffix.as_ref() && !s.is_empty() { return Ok(s.clone()); } let vendor_path = installation_manager.vendor_dir(); let autoload_path = vendor_path.join("autoload.php"); if autoload_path.exists() && let Ok(content) = std::fs::read_to_string(&autoload_path) && let Some(start) = content.find("ComposerAutoloaderInit") { let rest = &content[start + "ComposerAutoloaderInit".len()..]; if let Some(end) = rest.find("::") { let candidate = &rest[..end]; if !candidate.is_empty() && candidate.chars().all(|c| c.is_ascii_hexdigit()) { return Ok(candidate.to_string()); } } } if locker.is_locked() && let Some(data) = locker.lock_data()? && !data.content_hash.is_empty() { return Ok(data.content_hash); } // Fall back to MD5 of the current timestamp (mirrors PHP's // `bin2hex(random_bytes(16))` — both produce a 32-char hex token // that participates only in classloader naming). let ts = format!("{:?}", std::time::SystemTime::now()); Ok(format!("{:x}", md5::compute(ts.as_bytes()))) } fn platform_check_mode_from_config(platform_check: &serde_json::Value) -> PlatformCheckMode { match platform_check { serde_json::Value::Bool(false) => PlatformCheckMode::Disabled, serde_json::Value::Bool(true) => PlatformCheckMode::Full, serde_json::Value::String(s) if s == "php-only" => PlatformCheckMode::PhpOnly, // Anything else (including JSON null / unknown strings) falls // through to `Full` — the safe default that PHP also picks // when the value is truthy-but-not-`"php-only"`. _ => PlatformCheckMode::Full, } } /// Escape a string for use in a PHP single-quoted string literal. pub fn php_escape(s: &str) -> String { s.replace('\\', "\\\\").replace('\'', "\\'") } /// Compute the file identifier matching Composer's `getFileIdentifier()`. /// This is the MD5 hex digest of `"package_name:path"`. pub fn file_identifier(package_name: &str, path: &str) -> String { let input = format!("{package_name}:{path}"); format!("{:x}", md5::compute(input.as_bytes())) } /// Extract a path or array of paths from a JSON value. /// Handles both string and array-of-strings (Composer allows both). fn json_to_paths(value: &serde_json::Value) -> Vec { match value { serde_json::Value::String(s) => vec![s.clone()], serde_json::Value::Array(arr) => arr .iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect(), _ => vec![], } } /// Strip trailing slash from a path component. fn strip_trailing_slash(s: &str) -> &str { s.trim_end_matches('/') } /// Normalize a PSR-4 namespace: ensure it ends with `\`. /// (The empty string "" is valid and is left as-is.) fn normalize_namespace(ns: &str) -> String { if ns.is_empty() || ns.ends_with('\\') { ns.to_string() } else { format!("{ns}\\") } } /// Build a PHP path expression from a base expression and a relative path component. /// /// For vendor packages: `base_expr` = `"$vendorDir"`, `pkg_path` = `"psr/log"`, /// `sub_path` = `"src/"` → result: `"$vendorDir . '/psr/log/src'"`. /// /// For root packages: `base_expr` = `"$baseDir"`, `pkg_path` = `""`, /// `sub_path` = `"src/"` → result: `"$baseDir . '/src'"`. fn build_path_expr(base_expr: &str, pkg_path: &str, sub_path: &str) -> String { let sub = strip_trailing_slash(sub_path); let combined = if pkg_path.is_empty() { sub.to_string() } else if sub.is_empty() { pkg_path.to_string() } else { format!("{pkg_path}/{sub}") }; if combined.is_empty() { base_expr.to_string() } else { format!("{base_expr} . '/{combined}'") } } /// Process an autoload JSON value and merge its rules into `data`. /// /// `pkg_path` is the package-relative path segment within vendor. /// For vendor packages it is `"vendor/name"` (e.g. `"psr/log"`). /// For the root package it is `""`. /// /// `dyn_base` is the dynamic PHP variable: `"$vendorDir"` or `"$baseDir"`. /// `static_base` is the static PHP expression: `"__DIR__ . '/..'"` or `"__DIR__ . '/../.'"`. fn process_autoload_value( autoload_val: &serde_json::Value, package_name: &str, pkg_path: &str, dyn_base: &str, static_base: &str, data: &mut AutoloadData, static_data: &mut AutoloadData, ) { // PSR-4 if let Some(psr4_obj) = autoload_val.get("psr-4").and_then(|v| v.as_object()) { for (ns_raw, paths_val) in psr4_obj { let ns = normalize_namespace(ns_raw); let paths = json_to_paths(paths_val); let entry = data.psr4.entry(ns.clone()).or_default(); let static_entry = static_data.psr4.entry(ns).or_default(); for path in paths { entry.push(build_path_expr(dyn_base, pkg_path, &path)); static_entry.push(build_path_expr(static_base, pkg_path, &path)); } } } // PSR-0 if let Some(psr0_obj) = autoload_val.get("psr-0").and_then(|v| v.as_object()) { for (ns_raw, paths_val) in psr0_obj { let ns = ns_raw.clone(); let paths = json_to_paths(paths_val); let entry = data.psr0.entry(ns.clone()).or_default(); let static_entry = static_data.psr0.entry(ns).or_default(); for path in paths { entry.push(build_path_expr(dyn_base, pkg_path, &path)); static_entry.push(build_path_expr(static_base, pkg_path, &path)); } } } // Files if let Some(files_arr) = autoload_val.get("files").and_then(|v| v.as_array()) { for file_val in files_arr { if let Some(file_path) = file_val.as_str() { let id = file_identifier(package_name, file_path); let expr = build_path_expr(dyn_base, pkg_path, file_path); let static_expr = build_path_expr(static_base, pkg_path, file_path); data.files.insert(id.clone(), expr); static_data.files.insert(id, static_expr); } } } } /// Collect autoload rules from all installed packages and the root package. /// /// Returns a tuple of `(dynamic_data, static_data)` where: /// - `dynamic_data` uses `$vendorDir` / `$baseDir` path expressions (for autoload_psr4.php, etc.) /// - `static_data` uses `__DIR__ . '/..'` path expressions (for autoload_static.php) fn collect_autoloads( installed: &InstalledPackages, root_autoload: Option<&serde_json::Value>, root_autoload_dev: Option<&serde_json::Value>, root_package_name: &str, dev_mode: bool, ) -> (AutoloadData, AutoloadData) { let mut data = AutoloadData { psr4: BTreeMap::new(), psr0: BTreeMap::new(), classmap: BTreeMap::new(), files: BTreeMap::new(), }; let mut static_data = AutoloadData { psr4: BTreeMap::new(), psr0: BTreeMap::new(), classmap: BTreeMap::new(), files: BTreeMap::new(), }; // Process each installed package for pkg in &installed.packages { if let Some(autoload_val) = &pkg.autoload { process_autoload_value( autoload_val, &pkg.name, &pkg.name, // pkg_path within vendor "$vendorDir", "__DIR__ . '/..'", &mut data, &mut static_data, ); } } // Process root package autoload if let Some(autoload_val) = root_autoload { process_autoload_value( autoload_val, root_package_name, "", // no pkg_path for root "$baseDir", "__DIR__ . '/../..'", &mut data, &mut static_data, ); } // Process root package autoload-dev (only in dev mode) if dev_mode && let Some(autoload_dev_val) = root_autoload_dev { process_autoload_value( autoload_dev_val, root_package_name, "", "$baseDir", "__DIR__ . '/../..'", &mut data, &mut static_data, ); } (data, static_data) } /// Generate `vendor/composer/autoload_psr4.php`. fn generate_autoload_psr4(data: &AutoloadData) -> String { let mut out = String::new(); out.push_str(")> = data.psr4.iter().collect(); sorted.sort_by(|(a, _), (b, _)| b.cmp(a)); for (ns, paths) in &sorted { let escaped_ns = php_escape(ns); if paths.len() == 1 { out.push_str(&format!(" '{}' => array({}),\n", escaped_ns, paths[0])); } else { out.push_str(&format!(" '{}' => array(\n", escaped_ns)); for path in paths.iter() { out.push_str(&format!(" {},\n", path)); } out.push_str(" ),\n"); } } out.push_str(");\n"); out } /// Generate `vendor/composer/autoload_namespaces.php` (PSR-0, empty for Phase 2.2). fn generate_autoload_namespaces(data: &AutoloadData) -> String { let mut out = String::new(); out.push_str(")> = data.psr0.iter().collect(); sorted.sort_by(|(a, _), (b, _)| b.cmp(a)); for (ns, paths) in &sorted { let escaped_ns = php_escape(ns); if paths.len() == 1 { out.push_str(&format!(" '{}' => array({}),\n", escaped_ns, paths[0])); } else { out.push_str(&format!(" '{}' => array(\n", escaped_ns)); for path in paths.iter() { out.push_str(&format!(" {},\n", path)); } out.push_str(" ),\n"); } } out.push_str(");\n"); out } /// Generate `vendor/composer/autoload_classmap.php`. /// Always contains `Composer\InstalledVersions`; classmap scanning deferred to Phase 5.6. fn generate_autoload_classmap(data: &AutoloadData) -> String { let mut out = String::new(); out.push_str(" $vendorDir . '/composer/InstalledVersions.php',\n", ); // Include any additional classmap entries from data for (class, path) in &data.classmap { let escaped_class = php_escape(class); out.push_str(&format!(" '{}' => {},\n", escaped_class, path)); } out.push_str(");\n"); out } /// Generate `vendor/composer/autoload_files.php`. /// Returns `None` if there are no files to autoload. fn generate_autoload_files(data: &AutoloadData) -> Option { if data.files.is_empty() { return None; } let mut out = String::new(); out.push_str(" {},\n", id, path)); } out.push_str(");\n"); Some(out) } /// Generate `vendor/composer/autoload_static.php`. /// /// `static_data` must have been collected with `__DIR__ . '/..'` path prefixes. fn generate_autoload_static(static_data: &AutoloadData, suffix: &str) -> String { let mut out = String::new(); out.push_str(" {path},\n")); } out.push_str(" );\n\n"); } // $prefixLengthsPsr4 — group by first character of namespace if !static_data.psr4.is_empty() { // Group namespaces by first character, sorted reverse let mut by_char: BTreeMap> = BTreeMap::new(); let mut sorted_ns: Vec<&String> = static_data.psr4.keys().collect(); sorted_ns.sort_by(|a, b| b.cmp(a)); for ns in sorted_ns { if let Some(first_char) = ns.chars().next() { // The byte length in PHP (single-quoted string with single backslashes) // ns in our data uses single backslash (stored as-is from JSON). let byte_len = ns.len(); by_char.entry(first_char).or_default().push((ns, byte_len)); } } out.push_str(" public static $prefixLengthsPsr4 = array (\n"); // Sort characters in reverse order too let mut chars: Vec = by_char.keys().copied().collect(); chars.sort_by(|a, b| b.cmp(a)); for ch in &chars { out.push_str(&format!(" '{ch}' =>\n array (\n")); if let Some(entries) = by_char.get(ch) { for (ns, len) in entries { let escaped_ns = php_escape(ns); out.push_str(&format!(" '{escaped_ns}' => {len},\n")); } } out.push_str(" ),\n"); } out.push_str(" );\n\n"); // $prefixDirsPsr4 out.push_str(" public static $prefixDirsPsr4 = array (\n"); let mut sorted_ns2: Vec<(&String, &Vec)> = static_data.psr4.iter().collect(); sorted_ns2.sort_by(|(a, _), (b, _)| b.cmp(a)); for (ns, paths) in sorted_ns2 { let escaped_ns = php_escape(ns); out.push_str(&format!(" '{escaped_ns}' =>\n array (\n")); for (i, path) in paths.iter().enumerate() { out.push_str(&format!(" {i} => {path},\n")); } out.push_str(" ),\n"); } out.push_str(" );\n\n"); } // $classMap — always contains Composer\InstalledVersions out.push_str(" public static $classMap = array (\n"); out.push_str( " 'Composer\\\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',\n", ); for (class, path) in &static_data.classmap { let escaped_class = php_escape(class); out.push_str(&format!(" '{}' => {},\n", escaped_class, path)); } out.push_str(" );\n\n"); // getInitializer out.push_str(" public static function getInitializer(ClassLoader $loader)\n {\n"); out.push_str(" return \\Closure::bind(function () use ($loader) {\n"); if !static_data.psr4.is_empty() { out.push_str(&format!( " $loader->prefixLengthsPsr4 = ComposerStaticInit{suffix}::$prefixLengthsPsr4;\n" )); out.push_str(&format!( " $loader->prefixDirsPsr4 = ComposerStaticInit{suffix}::$prefixDirsPsr4;\n" )); } out.push_str(&format!( " $loader->classMap = ComposerStaticInit{suffix}::$classMap;\n" )); out.push_str("\n }, null, ClassLoader::class);\n }\n}\n"); out } /// Generate `vendor/composer/platform_check.php`. /// /// Returns `None` if mode is `Disabled` or there are no relevant requirements. fn generate_platform_check( packages: &[LockedPackage], root_require: Option<&serde_json::Value>, mode: &PlatformCheckMode, dev_package_names: &IndexSet, ) -> Option { if matches!(mode, PlatformCheckMode::Disabled) { return None; } // Collect PHP version constraint from root require let mut php_constraint: Option = None; if let Some(req_obj) = root_require.and_then(|v| v.as_object()) && let Some(v) = req_obj.get("php").and_then(|v| v.as_str()) { php_constraint = Some(v.to_string()); } // Collect extension requirements from packages (prod only) let mut ext_reqs: Vec<(String, String)> = Vec::new(); if matches!(mode, PlatformCheckMode::Full) { for pkg in packages { let is_dev = dev_package_names.contains(&pkg.name.to_lowercase()); if is_dev { continue; } for (req_name, req_constraint) in &pkg.require { let lower = req_name.to_lowercase(); if lower.starts_with("ext-") { ext_reqs.push((req_name.clone(), req_constraint.clone())); } } } ext_reqs.sort(); ext_reqs.dedup(); } if php_constraint.is_none() && ext_reqs.is_empty() { return None; } let mut out = String::new(); out.push_str("= 50600)) {\n"); out.push_str(&format!( " $issues[] = 'Your Composer dependencies require a PHP version \"{escaped}\". You are running ' . PHP_VERSION . '.';\n" )); out.push_str("}\n\n"); } for (ext_name, _constraint) in &ext_reqs { let ext_short = ext_name.trim_start_matches("ext-"); let escaped_ext = php_escape(ext_short); out.push_str(&format!("if (!extension_loaded('{escaped_ext}')) {{\n")); out.push_str(&format!( " $issues[] = 'Your Composer dependencies require the \"{escaped_ext}\" PHP extension to be installed.';\n" )); out.push_str("}\n\n"); } out.push_str("if ($issues) {\n"); out.push_str(" if (!headers_sent()) {\n"); out.push_str(" header('HTTP/1.1 500 Internal Server Error');\n"); out.push_str(" }\n"); out.push_str(" if (!ini_get('display_errors')) {\n"); out.push_str(" if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {\n"); out.push_str(" fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL);\n"); out.push_str(" } elseif (!headers_sent()) {\n"); out.push_str(" echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL;\n"); out.push_str(" }\n"); out.push_str(" }\n"); out.push_str(" trigger_error(\n"); out.push_str( " 'Composer detected issues in your platform: ' . implode(' ', $issues),\n", ); out.push_str(" E_USER_ERROR\n"); out.push_str(" );\n"); out.push_str("}\n"); Some(out) } /// Generate `vendor/composer/autoload_real.php`. fn generate_autoload_real( suffix: &str, has_files: bool, classmap_authoritative: bool, apcu: bool, apcu_prefix: Option<&str>, has_platform_check: bool, ) -> String { let mut out = String::new(); out.push_str("register(true);\n"); if classmap_authoritative { out.push_str(" $loader->setClassMapAuthoritative(true);\n"); } if apcu { let prefix = apcu_prefix.unwrap_or(suffix); let escaped = php_escape(prefix); out.push_str(&format!(" $loader->setApcuPrefix('{escaped}');\n")); } if has_files { out.push('\n'); out.push_str(&format!( " $filesToLoad = \\Composer\\Autoload\\ComposerStaticInit{suffix}::$files;\n" )); out.push_str( " $requireFile = \\Closure::bind(static function ($fileIdentifier, $file) {\n", ); out.push_str( " if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {\n", ); out.push_str( " $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;\n", ); out.push('\n'); out.push_str(" require $file;\n"); out.push_str(" }\n"); out.push_str(" }, null, null);\n"); out.push_str(" foreach ($filesToLoad as $fileIdentifier => $file) {\n"); out.push_str(" $requireFile($fileIdentifier, $file);\n"); out.push_str(" }\n"); } out.push('\n'); out.push_str(" return $loader;\n"); out.push_str(" }\n"); out.push_str("}\n"); out } /// Generate `vendor/autoload.php` (the entry point). fn generate_autoload_php(suffix: &str) -> String { let mut out = String::new(); out.push_str(" String { let dev_str = if dev_mode { "true" } else { "false" }; let mut out = String::new(); out.push_str(" array(\n"); out.push_str(&format!(" 'name' => '{}',\n", php_escape(root_name))); out.push_str(" 'pretty_version' => 'dev-main',\n"); out.push_str(" 'version' => 'dev-main',\n"); out.push_str(" 'reference' => null,\n"); out.push_str(&format!(" 'type' => '{}',\n", php_escape(root_type))); out.push_str(" 'install_path' => __DIR__ . '/../../',\n"); out.push_str(" 'aliases' => array(),\n"); out.push_str(&format!(" 'dev' => {dev_str},\n")); out.push_str(" ),\n"); out.push_str(" 'versions' => array(\n"); for pkg in &installed.packages { let version = &pkg.version; let version_normalized = pkg.version_normalized.as_deref().unwrap_or(version); let pkg_type = pkg.package_type.as_deref().unwrap_or("library"); let is_dev = installed .dev_package_names .iter() .any(|n| n.eq_ignore_ascii_case(&pkg.name)); let is_dev_str = if is_dev { "true" } else { "false" }; out.push_str(&format!(" '{}' => array(\n", php_escape(&pkg.name))); out.push_str(&format!( " 'pretty_version' => '{}',\n", php_escape(version) )); out.push_str(&format!( " 'version' => '{}',\n", php_escape(version_normalized) )); out.push_str(" 'reference' => null,\n"); out.push_str(&format!( " 'type' => '{}',\n", php_escape(pkg_type) )); // Install path relative to vendor/composer/installed.php: __DIR__ . '/./' . relative_name // The install_path stored is like '../psr/log', relative to vendor/composer/ // So from vendor/composer/, the package is at __DIR__ . '/../psr/log/' out.push_str(&format!( " 'install_path' => __DIR__ . '/../{}/',\n", pkg.name )); out.push_str(" 'aliases' => array(),\n"); out.push_str(&format!(" 'dev_requirement' => {is_dev_str},\n")); out.push_str(" ),\n"); } out.push_str(" ),\n"); out.push_str(");\n"); out } /// Determine the autoloader suffix. /// /// Priority: /// 1. Existing `vendor/autoload.php` suffix (carry over to avoid breaking existing references). /// 2. Lock file `content-hash` (if locked). /// 3. Fall back to a timestamp-based hex string. pub fn determine_suffix(working_dir: &Path, vendor_dir: &Path) -> anyhow::Result { // Try existing autoload.php let autoload_path = vendor_dir.join("autoload.php"); if autoload_path.exists() { let content = std::fs::read_to_string(&autoload_path)?; if let Some(start) = content.find("ComposerAutoloaderInit") { let rest = &content[start + "ComposerAutoloaderInit".len()..]; if let Some(end) = rest.find("::") { let suffix = &rest[..end]; if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_hexdigit()) { return Ok(suffix.to_string()); } } } } // Try composer.lock content-hash let lock_path = working_dir.join("composer.lock"); if lock_path.exists() { let lock = crate::repository::lockfile::LockFile::read_from_file(&lock_path)?; return Ok(lock.content_hash); } // Fall back to MD5 of current timestamp let ts = format!("{:?}", std::time::SystemTime::now()); Ok(format!("{:x}", md5::compute(ts.as_bytes()))) } /// Generate all autoloader files for the given project. /// /// This is the main entry point called by `install` and `dump-autoload`. pub fn generate(config: &AutoloadConfig) -> anyhow::Result { // 1. Read installed.json let installed = InstalledPackages::read(&config.vendor_dir)?; // 2. Read root package autoload from composer.json let composer_json_path = config.project_dir.join("composer.json"); let (root_autoload, root_autoload_dev, root_name, root_type) = if composer_json_path.exists() { let content = std::fs::read_to_string(&composer_json_path)?; let value: serde_json::Value = serde_json::from_str(&content)?; ( value.get("autoload").cloned(), value.get("autoload-dev").cloned(), value .get("name") .and_then(|n| n.as_str()) .unwrap_or("__root__") .to_string(), value .get("type") .and_then(|t| t.as_str()) .unwrap_or("project") .to_string(), ) } else { (None, None, "__root__".to_string(), "project".to_string()) }; // 3. Collect autoload data let (mut data, mut static_data) = collect_autoloads( &installed, root_autoload.as_ref(), root_autoload_dev.as_ref(), &root_name, config.dev_mode, ); // 3a. Read classmap dirs declared in composer.json let excluded: Vec = root_autoload .as_ref() .and_then(|v| v.get("exclude-from-classmap")) .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect() }) .unwrap_or_default(); // Scan explicit classmap dirs from all packages let mut classmap_dirs: Vec = Vec::new(); // Collect classmap dirs from installed packages for pkg in &installed.packages { if let Some(autoload_val) = &pkg.autoload && let Some(cm_arr) = autoload_val.get("classmap").and_then(|v| v.as_array()) { for cm_val in cm_arr { if let Some(cm_path) = cm_val.as_str() { let abs = config.vendor_dir.join(&pkg.name).join(cm_path); classmap_dirs.push(abs); } } } } // Collect classmap dirs from root autoload if let Some(autoload_val) = root_autoload.as_ref() && let Some(cm_arr) = autoload_val.get("classmap").and_then(|v| v.as_array()) { for cm_val in cm_arr { if let Some(cm_path) = cm_val.as_str() { let abs = config.project_dir.join(cm_path); classmap_dirs.push(abs); } } } // Scan classmap dirs let mut ambiguous_found = false; if !classmap_dirs.is_empty() { let scanned = scan_classmap_dirs( &classmap_dirs, &config.vendor_dir, &config.project_dir, &excluded, ); for (class, path_expr) in scanned { if let Some(existing) = data.classmap.get(&class) && existing != &path_expr { ambiguous_found = true; } // Also generate the static expression // We store the dynamic expression in data.classmap; static_data.classmap // will be populated similarly. For now we insert into both. data.classmap.entry(class.clone()).or_insert(path_expr); // Generate corresponding static expr by replacing dynamic prefixes // (static_data classmap is populated in the static pass below) } } // 3b. Optimize mode: scan PSR-4/PSR-0 dirs for classmap let do_optimize = config.optimize || config.classmap_authoritative; let mut psr_violations: Vec = Vec::new(); if do_optimize { let (opt_dyn, opt_static, violations) = scan_psr_for_classmap( &data.psr4, &data.psr0, &config.vendor_dir, &config.project_dir, &excluded, ); psr_violations = violations; for (class, path_expr) in opt_dyn { if let Some(existing) = data.classmap.get(&class) && existing != &path_expr { ambiguous_found = true; } data.classmap.entry(class).or_insert(path_expr); } for (class, path_expr) in opt_static { static_data.classmap.entry(class).or_insert(path_expr); } } // 3c. Handle strict-psr violations if config.strict_psr && !psr_violations.is_empty() { for violation in &psr_violations { eprintln!("PSR violation: {violation}"); } return Err(anyhow::anyhow!( "PSR mapping violations detected (--strict-psr). Run without --strict-psr to ignore." )); } // 4. Generate and write files let composer_dir = config.vendor_dir.join("composer"); std::fs::create_dir_all(&composer_dir)?; std::fs::write( composer_dir.join("autoload_psr4.php"), generate_autoload_psr4(&data), )?; std::fs::write( composer_dir.join("autoload_namespaces.php"), generate_autoload_namespaces(&data), )?; std::fs::write( composer_dir.join("autoload_classmap.php"), generate_autoload_classmap(&data), )?; if let Some(files_content) = generate_autoload_files(&data) { std::fs::write(composer_dir.join("autoload_files.php"), files_content)?; } else { // Remove stale file if it exists let files_path = composer_dir.join("autoload_files.php"); if files_path.exists() { std::fs::remove_file(files_path)?; } } // 4a. Generate platform_check.php if needed let dev_package_names_set: IndexSet = installed .dev_package_names .iter() .map(|n| n.to_lowercase()) .collect(); // Re-read composer.json for root require (not from autoload, but from root "require" key) let root_require_val: Option = if composer_json_path.exists() { let content = std::fs::read_to_string(&composer_json_path)?; let value: serde_json::Value = serde_json::from_str(&content)?; value.get("require").cloned() } else { None }; let all_locked: Vec = { // Collect locked packages from installed for platform check // (installed.packages are LockedPackage-compatible via InstalledPackageEntry) // We'll build minimal LockedPackage-like data from installed entries installed .packages .iter() .map(|p| crate::repository::lockfile::LockedPackage { name: p.name.clone(), version: p.version.clone(), version_normalized: p.version_normalized.clone(), source: None, dist: None, require: std::collections::BTreeMap::new(), require_dev: std::collections::BTreeMap::new(), conflict: std::collections::BTreeMap::new(), provide: std::collections::BTreeMap::new(), replace: std::collections::BTreeMap::new(), suggest: None, package_type: p.package_type.clone(), autoload: p.autoload.clone(), autoload_dev: None, license: None, description: None, homepage: None, keywords: None, authors: None, support: None, funding: None, time: None, extra_fields: std::collections::BTreeMap::new(), }) .collect() }; let effective_mode = if config.ignore_platform_reqs { PlatformCheckMode::Disabled } else { config.platform_check.clone() }; let platform_check_content = generate_platform_check( &all_locked, root_require_val.as_ref(), &effective_mode, &dev_package_names_set, ); let has_platform_check = platform_check_content.is_some(); if let Some(content) = platform_check_content { std::fs::write(composer_dir.join("platform_check.php"), content)?; } else { let pc_path = composer_dir.join("platform_check.php"); if pc_path.exists() { std::fs::remove_file(pc_path)?; } } let has_files = !data.files.is_empty(); let use_apcu = config.apcu || config.apcu_prefix.is_some(); std::fs::write( composer_dir.join("autoload_static.php"), generate_autoload_static(&static_data, &config.suffix), )?; std::fs::write( composer_dir.join("autoload_real.php"), generate_autoload_real( &config.suffix, has_files, config.classmap_authoritative, use_apcu, config.apcu_prefix.as_deref(), has_platform_check, ), )?; std::fs::write( config.vendor_dir.join("autoload.php"), generate_autoload_php(&config.suffix), )?; // 5. Copy ClassLoader.php, InstalledVersions.php, LICENSE std::fs::write(composer_dir.join("ClassLoader.php"), CLASSLOADER_PHP)?; std::fs::write( composer_dir.join("InstalledVersions.php"), INSTALLED_VERSIONS_PHP, )?; std::fs::write(composer_dir.join("LICENSE"), COMPOSER_LICENSE)?; // 6. Generate installed.php std::fs::write( composer_dir.join("installed.php"), generate_installed_php(&root_name, &root_type, &installed, config.dev_mode), )?; Ok(GenerateResult { class_count: data.classmap.len(), has_psr_violations: !psr_violations.is_empty(), has_ambiguous_classes: ambiguous_found, }) } #[cfg(test)] mod tests { use super::*; use crate::repository::installed::{InstalledPackageEntry, InstalledPackages}; use std::collections::BTreeMap; use tempfile::tempdir; fn make_installed_pkg(name: &str, version: &str) -> InstalledPackageEntry { InstalledPackageEntry { name: name.to_string(), version: version.to_string(), version_normalized: None, source: None, dist: None, package_type: Some("library".to_string()), install_path: Some(format!("../{name}")), autoload: None, aliases: vec![], homepage: None, support: None, extra_fields: BTreeMap::new(), } } fn make_installed_pkg_with_autoload( name: &str, version: &str, autoload: serde_json::Value, ) -> InstalledPackageEntry { let mut entry = make_installed_pkg(name, version); entry.autoload = Some(autoload); entry } // ------------------------------------------------------------------------- // Helper function tests // ------------------------------------------------------------------------- #[test] fn test_php_escape_backslash() { assert_eq!(php_escape("Psr\\Log\\"), "Psr\\\\Log\\\\"); } #[test] fn test_php_escape_quote() { assert_eq!(php_escape("don't"), "don\\'t"); } #[test] fn test_php_escape_mixed() { assert_eq!(php_escape("A\\B'C"), "A\\\\B\\'C"); } #[test] fn test_file_identifier_known_vector() { // Known test vector from Composer docs: // md5("symfony/polyfill-php80:bootstrap.php") = "a4a119a56e50fbb293281d9a48007e0e" let id = file_identifier("symfony/polyfill-php80", "bootstrap.php"); assert_eq!(id, "a4a119a56e50fbb293281d9a48007e0e"); } #[test] fn test_file_identifier_format() { let id = file_identifier("psr/log", "src/functions.php"); // Should be 32 hex chars (MD5) assert_eq!(id.len(), 32); assert!(id.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn test_json_to_paths_string() { let v = serde_json::json!("src/"); assert_eq!(json_to_paths(&v), vec!["src/"]); } #[test] fn test_json_to_paths_array() { let v = serde_json::json!(["src/", "lib/"]); assert_eq!(json_to_paths(&v), vec!["src/", "lib/"]); } #[test] fn test_json_to_paths_invalid() { let v = serde_json::json!(42); assert!(json_to_paths(&v).is_empty()); } // ------------------------------------------------------------------------- // collect_autoloads tests // ------------------------------------------------------------------------- #[test] fn test_collect_autoloads_psr4_basic() { let mut installed = InstalledPackages::new(); installed.upsert(make_installed_pkg_with_autoload( "psr/log", "3.0.2", serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}), )); let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false); assert!(data.psr4.contains_key("Psr\\Log\\")); let paths = &data.psr4["Psr\\Log\\"]; assert_eq!(paths.len(), 1); assert_eq!(paths[0], "$vendorDir . '/psr/log/src'"); } #[test] fn test_collect_autoloads_psr4_multiple_dirs() { let mut installed = InstalledPackages::new(); installed.upsert(make_installed_pkg_with_autoload( "monolog/monolog", "3.8.0", serde_json::json!({"psr-4": {"Monolog\\": ["src/Monolog", "lib/"]}}), )); let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false); let paths = &data.psr4["Monolog\\"]; assert_eq!(paths.len(), 2); assert_eq!(paths[0], "$vendorDir . '/monolog/monolog/src/Monolog'"); assert_eq!(paths[1], "$vendorDir . '/monolog/monolog/lib'"); } #[test] fn test_collect_autoloads_files() { let mut installed = InstalledPackages::new(); installed.upsert(make_installed_pkg_with_autoload( "symfony/polyfill-php80", "1.32.0", serde_json::json!({"files": ["bootstrap.php"]}), )); let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false); // The identifier should match Composer's MD5 computation let expected_id = "a4a119a56e50fbb293281d9a48007e0e"; assert!(data.files.contains_key(expected_id)); assert_eq!( data.files[expected_id], "$vendorDir . '/symfony/polyfill-php80/bootstrap.php'" ); } #[test] fn test_collect_autoloads_root_package() { let installed = InstalledPackages::new(); let root_autoload = serde_json::json!({"psr-4": {"App\\": "src/"}}); let (data, _static_data) = collect_autoloads( &installed, Some(&root_autoload), None, "myproject/app", false, ); assert!(data.psr4.contains_key("App\\")); let paths = &data.psr4["App\\"]; assert_eq!(paths[0], "$baseDir . '/src'"); } #[test] fn test_collect_autoloads_root_autoload_dev_included_when_dev() { let installed = InstalledPackages::new(); let root_autoload_dev = serde_json::json!({"psr-4": {"Tests\\": "tests/"}}); let (data, _) = collect_autoloads( &installed, None, Some(&root_autoload_dev), "myproject/app", true, // dev_mode = true ); assert!(data.psr4.contains_key("Tests\\")); } #[test] fn test_collect_autoloads_root_autoload_dev_excluded_when_no_dev() { let installed = InstalledPackages::new(); let root_autoload_dev = serde_json::json!({"psr-4": {"Tests\\": "tests/"}}); let (data, _) = collect_autoloads( &installed, None, Some(&root_autoload_dev), "myproject/app", false, // dev_mode = false ); assert!(!data.psr4.contains_key("Tests\\")); } // ------------------------------------------------------------------------- // generate_autoload_psr4 tests // ------------------------------------------------------------------------- #[test] fn test_generate_autoload_psr4_output() { let mut installed = InstalledPackages::new(); installed.upsert(make_installed_pkg_with_autoload( "psr/log", "3.0.2", serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}), )); let (data, _) = collect_autoloads(&installed, None, None, "__root__", false); let output = generate_autoload_psr4(&data); assert!(output.contains(" 8")); } // ------------------------------------------------------------------------- // generate_autoload_real tests // ------------------------------------------------------------------------- #[test] fn test_generate_autoload_real_with_files() { let output = generate_autoload_real("abc123", true, false, false, None, false); assert!(output.contains("class ComposerAutoloaderInitabc123")); assert!(output.contains("ComposerStaticInitabc123::$files")); assert!(output.contains("$requireFile")); assert!(output.contains("__composer_autoload_files")); } #[test] fn test_generate_autoload_real_without_files() { let output = generate_autoload_real("abc123", false, false, false, None, false); assert!(output.contains("class ComposerAutoloaderInitabc123")); assert!(!output.contains("$filesToLoad")); assert!(!output.contains("__composer_autoload_files")); } #[test] fn test_generate_autoload_real_apcu() { let output = generate_autoload_real("abc123", false, false, true, None, false); assert!(output.contains("setApcuPrefix('abc123')")); } #[test] fn test_generate_autoload_real_apcu_custom_prefix() { let output = generate_autoload_real("abc123", false, false, true, Some("myprefix"), false); assert!(output.contains("setApcuPrefix('myprefix')")); } #[test] fn test_generate_autoload_real_platform_check() { let output = generate_autoload_real("abc123", false, false, false, None, true); assert!(output.contains("require __DIR__ . '/platform_check.php'")); } #[test] fn test_generate_autoload_real_no_platform_check() { let output = generate_autoload_real("abc123", false, false, false, None, false); assert!(!output.contains("platform_check.php")); } // ------------------------------------------------------------------------- // generate_installed_php tests // ------------------------------------------------------------------------- #[test] fn test_generate_installed_php() { let mut installed = InstalledPackages::new(); let mut pkg = make_installed_pkg("psr/log", "3.0.2"); pkg.version_normalized = Some("3.0.2.0".to_string()); installed.upsert(pkg); let output = generate_installed_php("myproject/app", "project", &installed, true); assert!(output.contains("'name' => 'myproject/app'")); assert!(output.contains("'type' => 'project'")); assert!(output.contains("'dev' => true")); assert!(output.contains("'psr/log'")); assert!(output.contains("'pretty_version' => '3.0.2'")); assert!(output.contains("'version' => '3.0.2.0'")); assert!(output.contains("__DIR__ . '/../psr/log/'")); assert!(output.contains("'dev_requirement' => false")); } #[test] fn test_generate_installed_php_dev_package() { let mut installed = InstalledPackages::new(); installed.upsert(make_installed_pkg("phpunit/phpunit", "11.0.0")); installed .dev_package_names .push("phpunit/phpunit".to_string()); let output = generate_installed_php("test/project", "project", &installed, true); assert!(output.contains("'dev_requirement' => true")); } // ------------------------------------------------------------------------- // generate() integration test // ------------------------------------------------------------------------- #[test] fn test_generate_full_roundtrip() { let dir = tempdir().unwrap(); let project_dir = dir.path().to_path_buf(); let vendor_dir = project_dir.join("vendor"); // Write a minimal composer.json std::fs::write( project_dir.join("composer.json"), r#"{"name": "test/project", "type": "project", "autoload": {"psr-4": {"App\\": "src/"}}}"#, ) .unwrap(); // Write a minimal installed.json let mut installed = InstalledPackages::new(); installed.upsert(make_installed_pkg_with_autoload( "psr/log", "3.0.2", serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}), )); installed.write(&vendor_dir).unwrap(); let config = AutoloadConfig { project_dir: project_dir.clone(), vendor_dir: vendor_dir.clone(), dev_mode: false, suffix: "abc123def456".to_string(), classmap_authoritative: false, optimize: false, apcu: false, apcu_prefix: None, strict_psr: false, strict_ambiguous: false, platform_check: PlatformCheckMode::Disabled, ignore_platform_reqs: false, }; generate(&config).unwrap(); // Verify all expected files exist assert!( vendor_dir.join("autoload.php").exists(), "autoload.php should exist" ); assert!( vendor_dir.join("composer/autoload_psr4.php").exists(), "autoload_psr4.php should exist" ); assert!( vendor_dir.join("composer/autoload_namespaces.php").exists(), "autoload_namespaces.php should exist" ); assert!( vendor_dir.join("composer/autoload_classmap.php").exists(), "autoload_classmap.php should exist" ); assert!( vendor_dir.join("composer/autoload_static.php").exists(), "autoload_static.php should exist" ); assert!( vendor_dir.join("composer/autoload_real.php").exists(), "autoload_real.php should exist" ); assert!( vendor_dir.join("composer/ClassLoader.php").exists(), "ClassLoader.php should exist" ); assert!( vendor_dir.join("composer/InstalledVersions.php").exists(), "InstalledVersions.php should exist" ); assert!( vendor_dir.join("composer/installed.php").exists(), "installed.php should exist" ); assert!( vendor_dir.join("composer/LICENSE").exists(), "LICENSE should exist" ); // autoload_files.php should NOT exist (no files autoloading) assert!( !vendor_dir.join("composer/autoload_files.php").exists(), "autoload_files.php should not exist when no files" ); // Check autoload.php content let autoload_php = std::fs::read_to_string(vendor_dir.join("autoload.php")).unwrap(); assert!(autoload_php.contains("ComposerAutoloaderInitabc123def456")); // Check autoload_psr4.php let psr4_php = std::fs::read_to_string(vendor_dir.join("composer/autoload_psr4.php")).unwrap(); assert!(psr4_php.contains("Psr\\\\Log\\\\")); assert!(psr4_php.contains("App\\\\")); assert!(psr4_php.contains("$vendorDir . '/psr/log/src'")); assert!(psr4_php.contains("$baseDir . '/src'")); // Check installed.php let installed_php = std::fs::read_to_string(vendor_dir.join("composer/installed.php")).unwrap(); assert!(installed_php.contains("'name' => 'test/project'")); assert!(installed_php.contains("'psr/log'")); } #[test] fn test_generate_with_files_autoload() { let dir = tempdir().unwrap(); let project_dir = dir.path().to_path_buf(); let vendor_dir = project_dir.join("vendor"); std::fs::write( project_dir.join("composer.json"), r#"{"name": "test/project", "type": "project"}"#, ) .unwrap(); let mut installed = InstalledPackages::new(); installed.upsert(make_installed_pkg_with_autoload( "symfony/polyfill-php80", "1.32.0", serde_json::json!({"files": ["bootstrap.php"]}), )); installed.write(&vendor_dir).unwrap(); let config = AutoloadConfig { project_dir: project_dir.clone(), vendor_dir: vendor_dir.clone(), dev_mode: false, suffix: "test".to_string(), classmap_authoritative: false, optimize: false, apcu: false, apcu_prefix: None, strict_psr: false, strict_ambiguous: false, platform_check: PlatformCheckMode::Disabled, ignore_platform_reqs: false, }; generate(&config).unwrap(); // autoload_files.php SHOULD exist assert!( vendor_dir.join("composer/autoload_files.php").exists(), "autoload_files.php should exist when files are present" ); let files_php = std::fs::read_to_string(vendor_dir.join("composer/autoload_files.php")).unwrap(); assert!(files_php.contains("a4a119a56e50fbb293281d9a48007e0e")); assert!(files_php.contains("$vendorDir . '/symfony/polyfill-php80/bootstrap.php'")); // autoload_real.php should contain the files loading block let real_php = std::fs::read_to_string(vendor_dir.join("composer/autoload_real.php")).unwrap(); assert!(real_php.contains("$filesToLoad")); } }