From 9148d0c707394f6e2529f04a767a602163d5bd95 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 21 Feb 2026 14:30:17 +0900 Subject: feat(install): add InstallConfig, platform warnings, and download progress Replace positional boolean parameters in install_from_lock with a structured InstallConfig. Add platform requirement warnings, download progress display, classmap-authoritative autoloader support, and prefer-source detection across install/update/require/remove commands. Co-Authored-By: Claude Opus 4.6 --- crates/mozart/src/autoload.rs | 16 ++- crates/mozart/src/commands/dump_autoload.rs | 1 + crates/mozart/src/commands/install.rs | 191 ++++++++++++++++++++++++---- crates/mozart/src/commands/remove.rs | 13 +- crates/mozart/src/commands/require.rs | 29 ++++- crates/mozart/src/commands/update.rs | 29 ++++- crates/mozart/src/downloader.rs | 102 ++++++++++++++- 7 files changed, 340 insertions(+), 41 deletions(-) (limited to 'crates/mozart/src') diff --git a/crates/mozart/src/autoload.rs b/crates/mozart/src/autoload.rs index 38c00d5..d4421a6 100644 --- a/crates/mozart/src/autoload.rs +++ b/crates/mozart/src/autoload.rs @@ -20,6 +20,8 @@ pub struct AutoloadConfig { /// 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, } /// Collected autoload mappings from all packages. @@ -427,7 +429,7 @@ fn generate_autoload_static(static_data: &AutoloadData, suffix: &str) -> String } /// Generate `vendor/composer/autoload_real.php`. -fn generate_autoload_real(suffix: &str, has_files: bool) -> String { +fn generate_autoload_real(suffix: &str, has_files: bool, classmap_authoritative: bool) -> String { let mut out = String::new(); out.push_str(" String { )); out.push_str(" $loader->register(true);\n"); + if classmap_authoritative { + out.push_str(" $loader->setClassMapAuthoritative(true);\n"); + } + if has_files { out.push('\n'); out.push_str(&format!( @@ -690,7 +696,7 @@ pub fn generate(config: &AutoloadConfig) -> anyhow::Result<()> { )?; std::fs::write( composer_dir.join("autoload_real.php"), - generate_autoload_real(&config.suffix, has_files), + generate_autoload_real(&config.suffix, has_files, config.classmap_authoritative), )?; std::fs::write( config.vendor_dir.join("autoload.php"), @@ -1022,7 +1028,7 @@ mod tests { #[test] fn test_generate_autoload_real_with_files() { - let output = generate_autoload_real("abc123", true); + let output = generate_autoload_real("abc123", true, false); assert!(output.contains("class ComposerAutoloaderInitabc123")); assert!(output.contains("ComposerStaticInitabc123::$files")); assert!(output.contains("$requireFile")); @@ -1031,7 +1037,7 @@ mod tests { #[test] fn test_generate_autoload_real_without_files() { - let output = generate_autoload_real("abc123", false); + let output = generate_autoload_real("abc123", false, false); assert!(output.contains("class ComposerAutoloaderInitabc123")); assert!(!output.contains("$filesToLoad")); assert!(!output.contains("__composer_autoload_files")); @@ -1104,6 +1110,7 @@ mod tests { vendor_dir: vendor_dir.clone(), dev_mode: false, suffix: "abc123def456".to_string(), + classmap_authoritative: false, }; generate(&config).unwrap(); @@ -1199,6 +1206,7 @@ mod tests { vendor_dir: vendor_dir.clone(), dev_mode: false, suffix: "test".to_string(), + classmap_authoritative: false, }; generate(&config).unwrap(); diff --git a/crates/mozart/src/commands/dump_autoload.rs b/crates/mozart/src/commands/dump_autoload.rs index ff3dfc2..2c17c55 100644 --- a/crates/mozart/src/commands/dump_autoload.rs +++ b/crates/mozart/src/commands/dump_autoload.rs @@ -70,6 +70,7 @@ pub fn execute(args: &DumpAutoloadArgs, cli: &super::Cli) -> anyhow::Result<()> vendor_dir, dev_mode, suffix, + classmap_authoritative: args.classmap_authoritative, })?; eprintln!("Generated autoload files"); diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs index ff53d1e..6c023a1 100644 --- a/crates/mozart/src/commands/install.rs +++ b/crates/mozart/src/commands/install.rs @@ -92,6 +92,41 @@ pub struct InstallArgs { pub ignore_platform_reqs: bool, } +/// Configuration for `install_from_lock`, replacing positional boolean parameters. +pub struct InstallConfig { + /// Install dev dependencies as well as prod dependencies. + pub dev_mode: bool, + /// Print what would happen without making changes. + pub dry_run: bool, + /// Skip generating autoload files. + pub no_autoloader: bool, + /// Suppress download progress bars. + pub no_progress: bool, + /// Ignore all platform requirements (php, ext-*, lib-*). + pub ignore_platform_reqs: bool, + /// Ignore specific platform requirements by name. + pub ignore_platform_req: Vec, + /// Optimize autoloader by generating a classmap. + pub optimize_autoloader: bool, + /// Use classmap-only autoloading (implies optimize_autoloader). + pub classmap_authoritative: bool, +} + +impl Default for InstallConfig { + fn default() -> Self { + Self { + dev_mode: true, + dry_run: false, + no_autoloader: false, + no_progress: false, + ignore_platform_reqs: false, + ignore_platform_req: vec![], + optimize_autoloader: false, + classmap_authoritative: false, + } + } +} + /// The action to take for a package during install. #[derive(Debug, PartialEq, Eq)] pub enum Action { @@ -201,27 +236,82 @@ pub fn cleanup_empty_vendor_dirs(vendor_dir: &Path) -> anyhow::Result<()> { Ok(()) } +/// Check whether a package name refers to a platform package. +/// +/// Platform packages are: names starting with "php", "ext-", or "lib-". +fn is_platform_package(name: &str) -> bool { + let lower = name.to_lowercase(); + lower == "php" + || lower.starts_with("php-") + || lower.starts_with("ext-") + || lower.starts_with("lib-") +} + +/// Warn about platform requirements found in locked packages. +/// +/// Iterates all locked packages' `require` fields, filters for platform entries, +/// and emits a warning for any that are not in the ignore list (unless +/// `ignore_platform_reqs` is set). +fn warn_platform_requirements( + packages: &[&lockfile::LockedPackage], + ignore_platform_reqs: bool, + ignore_platform_req: &[String], +) { + if ignore_platform_reqs { + return; + } + + let ignored_set: HashSet = ignore_platform_req + .iter() + .map(|s| s.to_lowercase()) + .collect(); + + for pkg in packages { + for (req_name, req_constraint) in &pkg.require { + if is_platform_package(req_name) { + let lower = req_name.to_lowercase(); + if !ignored_set.contains(&lower) { + eprintln!( + "{}", + console::warning(&format!( + "Platform requirement {req_name} {req_constraint} (required by {}) \ + has not been verified. Platform detection is not yet fully implemented.", + pkg.name + )) + ); + } + } + } + } +} + +/// Create a download progress tracker for a package. +fn make_progress(show: bool, pkg_name: &str, version: &str) -> downloader::DownloadProgress { + downloader::DownloadProgress::new(show, format!("{pkg_name} ({version})")) +} + /// Install packages from a lock file into vendor/. /// /// Used by both the `install` and `update` commands. /// /// This function: /// 1. Determines which packages to install (prod + optionally dev) -/// 2. Reads currently installed packages -/// 3. Computes install/update/skip/removal operations -/// 4. Prints a summary -/// 5. Executes downloads and removals (unless dry_run) -/// 6. Writes vendor/composer/installed.json -/// 7. Cleans up empty vendor directories -/// 8. Generates the autoloader (unless no_autoloader) +/// 2. Warns about platform requirements (unless ignored) +/// 3. Reads currently installed packages +/// 4. Computes install/update/skip/removal operations +/// 5. Prints a summary +/// 6. Executes downloads with optional progress bars (unless dry_run) +/// 7. Writes vendor/composer/installed.json +/// 8. Cleans up empty vendor directories +/// 9. Generates the autoloader (unless no_autoloader) pub fn install_from_lock( lock: &lockfile::LockFile, working_dir: &Path, vendor_dir: &Path, - dev_mode: bool, - dry_run: bool, - no_autoloader: bool, + config: &InstallConfig, ) -> anyhow::Result<()> { + let dev_mode = config.dev_mode; + // Step 1: Determine which packages to install let mut packages_to_install: Vec<&lockfile::LockedPackage> = lock.packages.iter().collect(); @@ -237,13 +327,20 @@ pub fn install_from_lock( } eprintln!("Verifying lock file contents can be installed on current platform."); - // Step 2: Read currently installed packages + // Step 2: Warn about platform requirements + warn_platform_requirements( + &packages_to_install, + config.ignore_platform_reqs, + &config.ignore_platform_req, + ); + + // Step 3: Read currently installed packages let installed = installed::InstalledPackages::read(vendor_dir)?; - // Step 3: Compute install operations + // Step 4: Compute install operations let (ops, removals) = compute_operations(&packages_to_install, &installed); - // Step 4: Print operation summary + // Step 5: Print operation summary let installs: Vec<_> = ops .iter() .filter(|(_, a)| matches!(a, Action::Install)) @@ -270,8 +367,8 @@ pub fn install_from_lock( ); } - // Step 5: Execute operations (unless dry_run) - if dry_run { + // Step 6: Execute operations (unless dry_run) + if config.dry_run { for (pkg, action) in &ops { match action { Action::Skip => {} @@ -305,13 +402,18 @@ pub fn install_from_lock( ) })?; + let mut progress = make_progress(!config.no_progress, &pkg.name, &pkg.version); + downloader::install_package( &dist.url, &dist.dist_type, dist.shasum.as_deref(), vendor_dir, &pkg.name, + Some(&mut progress), )?; + + progress.finish(); } // Handle removals @@ -323,12 +425,12 @@ pub fn install_from_lock( } } - // Step 6: Clean up empty vendor namespace directories + // Step 7: Clean up empty vendor namespace directories if !removals.is_empty() { cleanup_empty_vendor_dirs(vendor_dir)?; } - // Step 7: Write updated vendor/composer/installed.json + // Step 8: Write updated vendor/composer/installed.json let mut new_installed = installed::InstalledPackages::new(); new_installed.dev = dev_mode; @@ -343,10 +445,27 @@ pub fn install_from_lock( new_installed.write(vendor_dir)?; - // Step 8: Generate autoloader (unless no_autoloader) - if !no_autoloader { + // Step 9: Generate autoloader (unless no_autoloader) + if !config.no_autoloader { eprintln!("Generating autoload files"); + if config.classmap_authoritative { + eprintln!( + "{}", + console::info( + "Classmap-authoritative mode: autoloader will only look up classes in the classmap." + ) + ); + } else if config.optimize_autoloader { + eprintln!( + "{}", + console::info( + "Optimize autoloader: classmap scanning is not yet fully supported. \ + PSR-4/PSR-0 autoloading will still be used." + ) + ); + } + let suffix = lock.content_hash.clone(); crate::autoload::generate(&crate::autoload::AutoloadConfig { @@ -354,6 +473,7 @@ pub fn install_from_lock( vendor_dir: vendor_dir.to_path_buf(), dev_mode, suffix, + classmap_authoritative: config.classmap_authoritative, })?; eprintln!("Generated autoload files"); @@ -432,18 +552,41 @@ pub fn execute(args: &InstallArgs, cli: &super::Cli) -> anyhow::Result<()> { } } - // Step 5: Determine dev mode and vendor directory + // Step 5: Warn about prefer-source (not yet supported) + let prefer_source = args.prefer_source + || args + .prefer_install + .as_deref() + .map(|s| s.eq_ignore_ascii_case("source")) + .unwrap_or(false); + if prefer_source { + eprintln!( + "{}", + console::warning( + "Warning: Source installs are not yet supported. Falling back to dist." + ) + ); + } + + // Step 6: Determine dev mode and vendor directory let dev_mode = !args.no_dev; let vendor_dir = working_dir.join("vendor"); - // Step 6: Delegate to shared install_from_lock() + // Step 7: Delegate to shared install_from_lock() install_from_lock( &lock, &working_dir, &vendor_dir, - dev_mode, - args.dry_run, - args.no_autoloader, + &InstallConfig { + dev_mode, + dry_run: args.dry_run, + no_autoloader: args.no_autoloader, + no_progress: args.no_progress, + ignore_platform_reqs: args.ignore_platform_reqs, + ignore_platform_req: args.ignore_platform_req.clone(), + optimize_autoloader: args.optimize_autoloader, + classmap_authoritative: args.classmap_authoritative, + }, ) } diff --git a/crates/mozart/src/commands/remove.rs b/crates/mozart/src/commands/remove.rs index 350ea39..b227df8 100644 --- a/crates/mozart/src/commands/remove.rs +++ b/crates/mozart/src/commands/remove.rs @@ -417,9 +417,16 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { &new_lock, &working_dir, &vendor_dir, - dev_mode, - false, // dry_run already handled above - false, // no_autoloader: always generate autoloader + &super::install::InstallConfig { + dev_mode, + dry_run: false, // dry_run already handled above + no_autoloader: false, // always generate autoloader + no_progress: args.no_progress, + ignore_platform_reqs: args.ignore_platform_reqs, + ignore_platform_req: args.ignore_platform_req.clone(), + optimize_autoloader: args.optimize_autoloader, + classmap_authoritative: args.classmap_authoritative, + }, )?; } diff --git a/crates/mozart/src/commands/require.rs b/crates/mozart/src/commands/require.rs index ee40d54..b9ec258 100644 --- a/crates/mozart/src/commands/require.rs +++ b/crates/mozart/src/commands/require.rs @@ -477,13 +477,36 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { // Install packages (unless --no-install or --dry-run) if !args.no_install && !args.dry_run { + // Warn about prefer-source (not yet supported) + let prefer_source = args.prefer_source + || args + .prefer_install + .as_deref() + .map(|s| s.eq_ignore_ascii_case("source")) + .unwrap_or(false); + if prefer_source { + eprintln!( + "{}", + crate::console::warning( + "Warning: Source installs are not yet supported. Falling back to dist." + ) + ); + } + super::install::install_from_lock( &new_lock, &working_dir, &vendor_dir, - dev_mode, - false, // dry_run already handled above - false, // no_autoloader: always generate autoloader + &super::install::InstallConfig { + dev_mode, + dry_run: false, // dry_run already handled above + no_autoloader: false, // always generate autoloader + no_progress: args.no_progress, + ignore_platform_reqs: args.ignore_platform_reqs, + ignore_platform_req: args.ignore_platform_req.clone(), + optimize_autoloader: args.optimize_autoloader, + classmap_authoritative: args.classmap_authoritative, + }, )?; } diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs index ba21432..d58d0a9 100644 --- a/crates/mozart/src/commands/update.rs +++ b/crates/mozart/src/commands/update.rs @@ -573,13 +573,36 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { // Step 12: Install packages (unless --no-install or --dry-run) if !args.no_install && !args.dry_run { + // Warn about prefer-source (not yet supported) + let prefer_source = args.prefer_source + || args + .prefer_install + .as_deref() + .map(|s| s.eq_ignore_ascii_case("source")) + .unwrap_or(false); + if prefer_source { + eprintln!( + "{}", + crate::console::warning( + "Warning: Source installs are not yet supported. Falling back to dist." + ) + ); + } + super::install::install_from_lock( &new_lock, &working_dir, &vendor_dir, - dev_mode, - false, // dry_run already checked above - args.no_autoloader, + &super::install::InstallConfig { + dev_mode, + dry_run: false, // dry_run already checked above + no_autoloader: args.no_autoloader, + no_progress: args.no_progress, + ignore_platform_reqs: args.ignore_platform_reqs, + ignore_platform_req: args.ignore_platform_req.clone(), + optimize_autoloader: args.optimize_autoloader, + classmap_authoritative: args.classmap_authoritative, + }, )?; } diff --git a/crates/mozart/src/downloader.rs b/crates/mozart/src/downloader.rs index 403f4d6..e7ded03 100644 --- a/crates/mozart/src/downloader.rs +++ b/crates/mozart/src/downloader.rs @@ -1,13 +1,86 @@ use sha1::{Digest, Sha1}; use std::collections::HashSet; use std::fs; -use std::io::{Cursor, Read}; +use std::io::{Cursor, Read, Write}; use std::path::Path; +/// A simple download progress tracker that writes to stderr. +/// +/// When `show` is false, all methods are no-ops. This lets callers toggle +/// progress display without branching on every call. +pub struct DownloadProgress { + show: bool, + total: u64, + downloaded: u64, + label: String, +} + +impl DownloadProgress { + /// Create a new progress tracker. + /// + /// - `show`: whether to actually display anything. + /// - `label`: a human-readable label (e.g. "psr/log (3.0.2)"). + pub fn new(show: bool, label: impl Into) -> Self { + Self { + show, + total: 0, + downloaded: 0, + label: label.into(), + } + } + + /// Set the total expected bytes from a `Content-Length` header. + pub fn set_total(&mut self, total: u64) { + self.total = total; + } + + /// Advance the downloaded byte count and redraw the line. + pub fn inc(&mut self, n: u64) { + if !self.show { + return; + } + self.downloaded += n; + let stderr = std::io::stderr(); + let mut out = stderr.lock(); + if let Some(pct) = (self.downloaded * 100).checked_div(self.total) { + let _ = write!( + out, + "\r Downloading {} ({}/{} bytes, {}%)", + self.label, self.downloaded, self.total, pct + ); + } else { + let _ = write!( + out, + "\r Downloading {} ({} bytes)", + self.label, self.downloaded + ); + } + let _ = out.flush(); + } + + /// Clear the progress line from the terminal. + pub fn finish(&self) { + if !self.show { + return; + } + let stderr = std::io::stderr(); + let mut out = stderr.lock(); + // Clear the line with spaces then return to start + let _ = write!(out, "\r{}\r", " ".repeat(80)); + let _ = out.flush(); + } +} + /// Download a dist archive from a URL. /// Returns the raw bytes of the downloaded archive. /// If `expected_shasum` is provided and non-empty, verifies SHA-1 of the downloaded bytes. -pub fn download_dist(url: &str, expected_shasum: Option<&str>) -> anyhow::Result> { +/// If `progress` is provided, increments it as bytes are received and sets the total from +/// the `Content-Length` response header. +pub fn download_dist( + url: &str, + expected_shasum: Option<&str>, + progress: Option<&mut DownloadProgress>, +) -> anyhow::Result> { let response = reqwest::blocking::get(url)?; if !response.status().is_success() { @@ -18,7 +91,26 @@ pub fn download_dist(url: &str, expected_shasum: Option<&str>) -> anyhow::Result ); } - let bytes = response.bytes()?.to_vec(); + // Stream the response body, updating progress as bytes arrive + let bytes = if let Some(pb) = progress { + if let Some(content_length) = response.content_length() { + pb.set_total(content_length); + } + let mut reader = response; + let mut buf = Vec::new(); + let mut chunk = [0u8; 8192]; + loop { + let n = reader.read(&mut chunk)?; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + pb.inc(n as u64); + } + buf + } else { + response.bytes()?.to_vec() + }; // Verify SHA-1 checksum if provided if let Some(shasum) = expected_shasum @@ -199,12 +291,14 @@ pub fn extract_tar_gz(data: &[u8], target_dir: &Path) -> anyhow::Result<()> { /// - `dist_shasum`: optional SHA-1 checksum /// - `vendor_dir`: path to `vendor/` directory /// - `package_name`: e.g. `"monolog/monolog"` +/// - `progress`: optional mutable progress tracker to update during download pub fn install_package( dist_url: &str, dist_type: &str, dist_shasum: Option<&str>, vendor_dir: &Path, package_name: &str, + progress: Option<&mut DownloadProgress>, ) -> anyhow::Result<()> { let target = vendor_dir.join(package_name); @@ -214,7 +308,7 @@ pub fn install_package( } fs::create_dir_all(&target)?; - let bytes = download_dist(dist_url, dist_shasum)?; + let bytes = download_dist(dist_url, dist_shasum, progress)?; match dist_type { "zip" => extract_zip(&bytes, &target)?, -- cgit v1.3.1