From 6d853db4e74f07abe480ab9532c914ba94623dc0 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Fri, 8 May 2026 21:33:52 +0900 Subject: fix(create-project): align with Composer's CreateProjectCommand pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the inline 370-line execute() into execute / install_project / install_root_package, mirroring Composer's three-method shape and argument order. Replace the bespoke caret/tilde/wildcard semver helpers with mozart_semver::VersionConstraint, harden stability inference (handle the @stability suffix and reject invalid values), and align user-facing wording ("Creating a ...", "Cannot create project directory ...", "Could not find package ...") with Composer's strings. Add the --ask directory prompt, the interactive VCS-removal prompt, the empty-target-directory bail, and the COMPOSER_ROOT_VERSION / COMPOSER env-var handling that the PHP command does after extraction. Custom repositories, the canonical Installer pathway, the signal handler, and script events are still deferred — see .ken/command_compat_plan/create_project.md. --- crates/mozart/src/commands/create_project.rs | 854 +++++++++++++++------------ 1 file changed, 487 insertions(+), 367 deletions(-) (limited to 'crates') diff --git a/crates/mozart/src/commands/create_project.rs b/crates/mozart/src/commands/create_project.rs index 3a6e6ba..ff9776d 100644 --- a/crates/mozart/src/commands/create_project.rs +++ b/crates/mozart/src/commands/create_project.rs @@ -1,5 +1,6 @@ use clap::Args; use indexmap::IndexMap; +use mozart_core::console::Console; use mozart_core::console_format; use mozart_core::package::{self, Stability}; use mozart_core::validation; @@ -112,7 +113,6 @@ pub struct CreateProjectArgs { /// VCS metadata directories to remove. const VCS_DIRS: &[&str] = &[ - ".git", ".svn", "_svn", "CVS", @@ -120,11 +120,22 @@ const VCS_DIRS: &[&str] = &[ ".arch-params", ".monotone", ".bzr", + ".git", ".hg", ".fslckout", "_FOSSIL_", ]; +/// Allowed stability values, ordered as `BasePackage::STABILITIES` keys. +const STABILITIES: &[&str] = &["stable", "RC", "beta", "alpha", "dev"]; + +/// Output of `install_root_package` — the bits that `install_project` needs back. +struct InstallRootPackageResult { + installed_from_vcs: bool, + target_dir: PathBuf, + concrete_version: String, +} + /// Derive the target directory from a package name (the part after `/`). fn dir_from_package_name(package_name: &str) -> &str { if let Some(slash) = package_name.rfind('/') { @@ -135,10 +146,7 @@ fn dir_from_package_name(package_name: &str) -> &str { } /// Remove VCS metadata directories from the target directory. -fn remove_vcs_metadata( - target_dir: &Path, - console: &mozart_core::console::Console, -) -> anyhow::Result<()> { +fn remove_vcs_metadata(target_dir: &Path, console: &Console) -> anyhow::Result<()> { for vcs_dir in VCS_DIRS { let path = target_dir.join(vcs_dir); if path.exists() { @@ -175,193 +183,276 @@ fn is_dir_non_empty(path: &Path) -> bool { .unwrap_or(false) } -pub async fn execute( - args: &CreateProjectArgs, - cli: &super::Cli, - console: &mozart_core::console::Console, -) -> anyhow::Result<()> { - // --- Handle deprecated / no-op flags --- - if args.prefer_source { - console.info(&console_format!( - "Source installs not yet supported, falling back to dist." - )); - } - - if args.dev { - console.info(&console_format!( - "The --dev flag is deprecated. Dev packages are installed by default." - )); - } - - if args.no_custom_installers { - console.info(&console_format!( - "The --no-custom-installers flag is deprecated. Use --no-plugins instead." - )); - } - - if !args.repository.is_empty() || args.repository_url.is_some() || args.add_repository { - console.info(&console_format!( - "Custom repository options (--repository, --repository-url, --add-repository) \ - are not yet supported and will be ignored." - )); +/// Render a path the same way Composer's `Filesystem::findShortestPath` does for +/// the `Creating a "..." project at "..."` line: relative when `dir` is contained +/// in `from`, otherwise the absolute path. +fn shortest_path(from: &Path, dir: &Path) -> String { + if let Ok(rel) = dir.strip_prefix(from) { + let s = rel.display().to_string(); + if s.is_empty() { ".".to_string() } else { s } + } else { + dir.display().to_string() } +} - // --- Step 1: Parse package argument --- - let package_arg = match &args.package { - Some(p) => p.clone(), - None => anyhow::bail!("Not enough arguments (missing: \"package\")."), +/// Mirror of Composer's `installProject`/`installRootPackage` stability-inference +/// branch. Returns the canonical (mixed-case) stability string and the parsed enum. +fn resolve_stability( + stability: Option<&str>, + package_version: Option<&str>, +) -> anyhow::Result<(String, Stability)> { + // Composer: when --stability is unset, infer from the package version. + let raw = if let Some(s) = stability { + s.to_string() + } else if let Some(v) = package_version { + // `^[^,\s]*?@(stable|RC|beta|alpha|dev)$` — pick out a trailing + // `@stability` flag attached to a single (no comma/whitespace) version. + if let Some(at_pos) = v.rfind('@') { + let (head, rest) = v.split_at(at_pos); + let suffix = &rest[1..]; + if !head.contains(',') + && !head.contains(char::is_whitespace) + && STABILITIES.iter().any(|k| suffix.eq_ignore_ascii_case(k)) + { + suffix.to_string() + } else { + parse_stability_from_version(v) + } + } else { + parse_stability_from_version(v) + } + } else { + "stable".to_string() }; - // Split on `:` or `=` to extract name and optional version from arg - let (package_name, version_from_arg) = match validation::parse_require_string(&package_arg) { - Ok((name, ver)) => (name.to_lowercase(), Some(ver)), - Err(_) => (package_arg.trim().to_lowercase(), None), + // Normalize to the canonical `BasePackage::STABILITIES` casing. + let normalized = STABILITIES + .iter() + .find(|k| k.eq_ignore_ascii_case(&raw)) + .copied(); + let normalized = match normalized { + Some(s) => s.to_string(), + None => anyhow::bail!( + "Invalid stability provided ({raw}), must be one of: {}", + STABILITIES.join(", ") + ), }; - // Validate the package name - if !validation::validate_package_name(&package_name) { - anyhow::bail!("Invalid package name: \"{package_name}\""); - } - - // Determine version: from arg string, then from --version flag - let version_constraint: Option = version_from_arg.or_else(|| args.version.clone()); + let stability = Stability::parse(&normalized); + Ok((normalized, stability)) +} - // --- Step 2: Determine target directory --- - let working_dir = cli.working_dir()?; +/// Mirror of `VersionParser::parseStability` — derive a stability flag from a +/// version constraint string (e.g. `"1.0.0-beta1"` → `"beta"`). +fn parse_stability_from_version(version: &str) -> String { + let v = version.trim(); + if v.to_lowercase().starts_with("dev-") || v.to_lowercase().ends_with("-dev") { + return "dev".to_string(); + } + if let Some(pos) = v.rfind('-') { + let suffix = v[pos + 1..].to_lowercase(); + let alpha: String = suffix.chars().take_while(|c| c.is_alphabetic()).collect(); + let stab = match alpha.as_str() { + "alpha" | "a" => "alpha", + "beta" | "b" => "beta", + "rc" => "RC", + "dev" => "dev", + _ => return "stable".to_string(), + }; + return stab.to_string(); + } + "stable".to_string() +} - let target_dir: PathBuf = { - let dir_name = args - .directory - .as_deref() - .unwrap_or_else(|| dir_from_package_name(&package_name)); - let p = PathBuf::from(dir_name); - if p.is_absolute() { - p - } else { - working_dir.join(p) - } +/// Match a Packagist version against a constraint string using `mozart_semver`. +fn version_satisfies_constraint(packagist_version: &str, constraint: &str) -> bool { + let parsed_constraint = match mozart_semver::VersionConstraint::parse(constraint) { + Ok(c) => c, + Err(_) => return false, + }; + let parsed_version = match mozart_semver::Version::parse(packagist_version) { + Ok(v) => v, + Err(_) => return false, }; + parsed_constraint.matches(&parsed_version) +} - // Validate target directory - if target_dir.is_file() { - anyhow::bail!( - "Target directory \"{}\" exists as a file.", - target_dir.display() - ); +pub async fn execute( + args: &CreateProjectArgs, + cli: &super::Cli, + console: &Console, +) -> anyhow::Result<()> { + // --- Deprecated / aliased flags --- + if args.dev { + console.write_error(&console_format!( + "You are using the deprecated option \"dev\". Dev packages are installed by default now." + )); } - if target_dir.is_dir() && is_dir_non_empty(&target_dir) { - anyhow::bail!( - "Target directory \"{}\" is not empty.", - target_dir.display() - ); + if args.no_custom_installers { + console.write_error(&console_format!( + "You are using the deprecated option \"no-custom-installers\". Use \"no-plugins\" instead." + )); } - // --- Step 3: Determine minimum stability --- - let minimum_stability: Stability = if let Some(ref s) = args.stability { - Stability::parse(s) - } else if let Some(ref v) = version_constraint { - // Infer from version string - version::stability_of(v) + // --- --ask interactive prompt for the project directory --- + let directory_arg: Option = if console.interactive && args.ask { + let package = args + .package + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Not enough arguments (missing: \"package\")."))?; + let lower = package.to_lowercase(); + let basename = dir_from_package_name(&lower).to_string(); + let answer = console.ask( + &console_format!("New project directory [{basename}]: "), + &basename, + ); + Some(answer) } else { - Stability::Stable + args.directory.clone() }; - // --- Step 4: Fetch package versions and find best match --- - console.info(&console_format!( - "Creating project from package {package_name}" - )); - console.info("Loading composer repositories with package information"); - - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); - let files_cache = mozart_registry::cache::Cache::files(&cache_config); - - let versions = packagist::fetch_package_versions(&package_name, &repo_cache).await?; - - // Find the best candidate matching the version constraint and stability - let best = if let Some(ref constraint) = version_constraint { - // Filter versions matching the constraint - versions - .iter() - .filter(|v| version::stability_of(&v.version_normalized) <= minimum_stability) - .filter(|v| { - // Simple version matching: check if version satisfies constraint - version_matches_constraint(&v.version, &v.version_normalized, constraint) - }) - .max_by(|a, b| { - version::compare_normalized_versions(&a.version_normalized, &b.version_normalized) - }) - .ok_or_else(|| { - anyhow::anyhow!( - "Could not find package \"{package_name}\" with constraint \"{constraint}\" \ - matching your minimum-stability ({minimum_stability:?})." - ) - })? + // --- Resolve --repository / --repository-url into a single Option> --- + let repositories: Option> = if !args.repository.is_empty() { + Some(args.repository.clone()) } else { - version::find_best_candidate(&versions, minimum_stability).ok_or_else(|| { - anyhow::anyhow!( - "Could not find a version of package \"{package_name}\" matching your \ - minimum-stability ({minimum_stability:?})." - ) - })? + args.repository_url.as_ref().map(|u| vec![u.clone()]) }; - let concrete_version = best.version.clone(); - - console.info(&console_format!( - "Installing {package_name} ({concrete_version})" - )); - - // --- Step 5: Create target directory and download+extract --- - std::fs::create_dir_all(&target_dir)?; - - let dist = best.dist.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "Package {package_name} ({concrete_version}) has no dist information — \ - source installs are not yet supported." - ) - })?; - - let mut progress = downloader::DownloadProgress::new( - !args.no_progress, - format!("{package_name} ({concrete_version})"), - ); + let install_dev_packages = !args.no_dev; + let prefer_install_source = args + .prefer_install + .as_deref() + .map(|s| s.eq_ignore_ascii_case("source")) + .unwrap_or(false); + let prefer_install_dist = args + .prefer_install + .as_deref() + .map(|s| s.eq_ignore_ascii_case("dist")) + .unwrap_or(false); + let prefer_source = args.prefer_source || prefer_install_source; + let prefer_dist = args.prefer_dist || prefer_install_dist; + let secure_http = !args.no_secure_http; - let bytes = downloader::download_dist( - &dist.url, - dist.shasum.as_deref(), - Some(&mut progress), - &files_cache, + install_project( + console, + cli, + args, + args.package.as_deref(), + directory_arg.as_deref(), + args.version.as_deref(), + args.stability.as_deref(), + prefer_source, + prefer_dist, + install_dev_packages, + repositories, + cli.no_plugins, + cli.no_scripts || args.no_scripts, + args.no_progress, + args.no_install, + secure_http, + args.add_repository, ) .await?; - progress.finish(); + Ok(()) +} - match dist.dist_type.as_str() { - "zip" => downloader::extract_zip(&bytes, &target_dir)?, - "tar" | "tar.gz" | "tgz" => downloader::extract_tar_gz(&bytes, &target_dir)?, - other => anyhow::bail!("Unsupported dist type: {other}"), +#[allow(clippy::too_many_arguments)] +async fn install_project( + console: &Console, + cli: &super::Cli, + args: &CreateProjectArgs, + package_name: Option<&str>, + directory: Option<&str>, + package_version: Option<&str>, + stability: Option<&str>, + prefer_source: bool, + prefer_dist: bool, + install_dev_packages: bool, + repositories: Option>, + disable_plugins: bool, + disable_scripts: bool, + no_progress: bool, + no_install: bool, + secure_http: bool, + add_repository: bool, +) -> anyhow::Result<()> { + let _ = (disable_plugins, disable_scripts, prefer_dist, secure_http); + + // Mozart does not yet support custom repositories on the create-project + // command — warn and ignore (deferred; tracked under priority 2). + if repositories.is_some() || add_repository { + console.write_error(&console_format!( + "Custom repository options (--repository, --repository-url, --add-repository) \ + are not yet supported and will be ignored." + )); } - console.info(&console_format!( - "Created project in {}", - target_dir.display() - )); + // --- installRootPackage: download + extract the root pkg into the target dir --- + let root_result = if let Some(name) = package_name { + Some( + install_root_package( + console, + cli, + args, + name, + directory, + package_version, + stability, + prefer_source, + prefer_dist, + install_dev_packages, + repositories.as_deref(), + disable_plugins, + disable_scripts, + no_progress, + secure_http, + ) + .await?, + ) + } else { + None + }; - // --- Step 7: VCS removal --- - // Remove VCS metadata unless --keep-vcs is set. - // If --remove-vcs is set, always remove. If --keep-vcs is set, always keep. - // Default (neither flag): remove. - let vcs_removed = args.remove_vcs || !args.keep_vcs; - if vcs_removed { - remove_vcs_metadata(&target_dir, console)?; + let Some(root) = root_result else { + // Composer falls back to `composer install` semantics when no package + // was given; Mozart does not yet support that mode. + anyhow::bail!("Not enough arguments (missing: \"package\")."); + }; + + let target_dir = root.target_dir.clone(); + let installed_from_vcs = root.installed_from_vcs; + let concrete_version = root.concrete_version.clone(); + + // --- VCS removal --- + // Composer asks the user when interactive (and `installed_from_vcs`); when + // non-interactive or `--remove-vcs` is set, it removes silently. With + // `--keep-vcs`, never remove. Mozart additionally extends "remove" to the + // dist-archive case (where there is no installed-from-vcs flag) so that + // .git directories shipped inside an archive get scrubbed. + let mut vcs_removed = false; + if !args.keep_vcs { + let should_remove = if installed_from_vcs { + args.remove_vcs + || !console.interactive + || console.confirm(&console_format!( + "Do you want to remove the existing VCS (.git, .svn..) history? [y,n]? " + )) + } else { + // Default for dist installs: scrub VCS metadata that may have been + // shipped inside the archive (matches Mozart's pre-split behaviour). + true + }; + if should_remove { + remove_vcs_metadata(&target_dir, console)?; + vcs_removed = true; + } } - // --- Step 6: Read composer.json and optionally install dependencies --- + // --- Read composer.json from the new project --- let composer_path = target_dir.join("composer.json"); - if !composer_path.exists() { - console.info(&console_format!( + console.write_error(&console_format!( "No composer.json found in {}. Skipping dependency installation.", target_dir.display() )); @@ -370,21 +461,21 @@ pub async fn execute( let mut raw = package::read_from_file(&composer_path)?; - // --- Step 8: Replace self.version constraints (only when VCS metadata is gone) --- + // --- Replace self.version constraints once VCS metadata is gone --- if vcs_removed { replace_self_version(&mut raw, &concrete_version); package::write_to_file(&raw, &composer_path)?; } - // --- Step 6 continued: dependency resolution and install --- - if args.no_install { + if no_install { console.info(&console_format!( "Skipping dependency installation (--no-install)." )); return Ok(()); } - let dev_mode = !args.no_dev; + // --- Resolve, lock, install dependencies --- + let dev_mode = install_dev_packages; let require: Vec<(String, String)> = raw .require @@ -407,6 +498,9 @@ pub async fn execute( .and_then(|v| v.as_bool()) .unwrap_or(false); + let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); + let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); + let request = ResolveRequest { root_name: raw.name.clone(), root_version: raw.version.clone(), @@ -472,7 +566,6 @@ pub async fn execute( }) .await?; - // Print change report (all will be installs for a new project) let changes = super::update::compute_update_changes(None, &new_lock, dev_mode); let installs: Vec<_> = changes @@ -498,15 +591,8 @@ pub async fn execute( let vendor_dir = target_dir.join("vendor"); - // Warn about prefer-source - 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 { - console.info(&console_format!( + console.write_error(&console_format!( "Source installs are not yet supported. Falling back to dist." )); } @@ -536,7 +622,7 @@ pub async fn execute( dev_mode, dry_run: false, no_autoloader: false, - no_progress: args.no_progress, + no_progress, ignore_platform_reqs: args.ignore_platform_reqs, ignore_platform_req: args.ignore_platform_req.clone(), optimize_autoloader, @@ -554,161 +640,196 @@ pub async fn execute( Ok(()) } -/// Check if a version satisfies a simple version constraint. -/// -/// Supports: -/// - Exact: "1.2.3", "v1.2.3" -/// - Caret: "^1.2.3" -/// - Tilde: "~1.2" -/// - Wildcard: "1.2.*" -/// - Comparison: ">=1.0", ">1.0", "<=2.0", "<2.0", "!=1.0" -/// - Stability flags: "^1.0@beta" -/// - Dev branches: "dev-master" -/// -/// Falls back to returning `true` for unrecognized constraints to avoid -/// incorrectly filtering packages. -fn version_matches_constraint(version: &str, version_normalized: &str, constraint: &str) -> bool { - // Strip stability flag from constraint (e.g. "^1.0@beta" → "^1.0") - let constraint = if let Some(pos) = constraint.find('@') { - &constraint[..pos] - } else { - constraint - }; +#[allow(clippy::too_many_arguments)] +async fn install_root_package( + console: &Console, + cli: &super::Cli, + _args: &CreateProjectArgs, + package_name: &str, + directory: Option<&str>, + package_version: Option<&str>, + stability: Option<&str>, + prefer_source: bool, + prefer_dist: bool, + install_dev_packages: bool, + repositories: Option<&[String]>, + disable_plugins: bool, + disable_scripts: bool, + no_progress: bool, + secure_http: bool, +) -> anyhow::Result { + let _ = ( + prefer_dist, + install_dev_packages, + repositories, + disable_scripts, + secure_http, + ); - let constraint = constraint.trim(); + // --- Parse name + version from the package argument --- + let (parsed_name, parsed_version) = match validation::parse_require_string(package_name) { + Ok((n, v)) => (n.to_lowercase(), Some(v)), + Err(_) => (package_name.trim().to_lowercase(), None), + }; + let name = parsed_name; + let package_version: Option = package_version.map(|s| s.to_string()).or(parsed_version); - // Handle dev-branch constraints - if constraint.starts_with("dev-") { - return version == constraint || version_normalized == constraint; + if !validation::validate_package_name(&name) { + anyhow::bail!("Invalid package name: \"{name}\""); } - // Handle wildcard constraints like "1.2.*" - if constraint.contains('*') { - let prefix = constraint.trim_end_matches('*').trim_end_matches('.'); - return version.starts_with(prefix) || version_normalized.starts_with(prefix); - } + // --- Determine target directory --- + let working_dir = cli.working_dir()?; - // Handle comparison operators - for op in &[">=", "<=", "!=", ">", "<"] { - if let Some(rest) = constraint.strip_prefix(op) { - let rest = rest.trim().trim_start_matches('v'); - let cmp = version::compare_normalized_versions(version_normalized, rest); - return match *op { - ">=" => cmp != std::cmp::Ordering::Less, - "<=" => cmp != std::cmp::Ordering::Greater, - "!=" => cmp != std::cmp::Ordering::Equal, - ">" => cmp == std::cmp::Ordering::Greater, - "<" => cmp == std::cmp::Ordering::Less, - _ => true, - }; + let mut directory_str: String = match directory { + Some(d) => d.to_string(), + None => { + let basename = dir_from_package_name(&name); + working_dir.join(basename).display().to_string() } + }; + // rtrim('/' | '\\') + while directory_str.ends_with('/') || directory_str.ends_with('\\') { + directory_str.pop(); } - // Handle caret constraint "^1.2.3" - if let Some(rest) = constraint.strip_prefix('^') { - let rest = rest.trim().trim_start_matches('v'); - return caret_matches(version_normalized, rest); + let mut target_dir = PathBuf::from(&directory_str); + if !target_dir.is_absolute() { + target_dir = working_dir.join(&target_dir); } - // Handle tilde constraint "~1.2.3" - if let Some(rest) = constraint.strip_prefix('~') { - let rest = rest.trim().trim_start_matches('v'); - return tilde_matches(version_normalized, rest); + if directory_str.is_empty() { + anyhow::bail!("Got an empty target directory, something went wrong"); } - // Exact match (possibly with "v" prefix) - let clean_constraint = constraint.trim_start_matches('v'); - version == constraint - || version == clean_constraint - || version_normalized.starts_with(clean_constraint) -} - -/// Check if a normalized version satisfies a caret constraint `^MAJOR.MINOR.PATCH`. -/// -/// Rules: -/// - If MAJOR > 0: any version in `[MAJOR.MINOR.PATCH, (MAJOR+1).0.0.0)` -/// - If MAJOR == 0 and MINOR > 0: any version in `[0.MINOR.PATCH, 0.(MINOR+1).0.0)` -/// - If MAJOR == 0 and MINOR == 0: any version in `[0.0.PATCH, 0.0.(PATCH+1))` -fn caret_matches(version_normalized: &str, constraint_base: &str) -> bool { - // Strip pre-release suffix from version for numeric comparison - let v_base = if let Some(pos) = version_normalized.find('-') { - &version_normalized[..pos] - } else { - version_normalized - }; - - let parse_parts = - |s: &str| -> Vec { s.split('.').filter_map(|p| p.parse().ok()).collect() }; + let short = shortest_path(&working_dir, &target_dir); + console.write_error(&console_format!( + "Creating a \"{package_name}\" project at \"{short}\"" + )); - let v_parts = parse_parts(v_base); - let c_parts = parse_parts(constraint_base); + if target_dir.exists() { + if !target_dir.is_dir() { + anyhow::bail!( + "Cannot create project directory at \"{}\", it exists as a file.", + target_dir.display() + ); + } + if is_dir_non_empty(&target_dir) { + anyhow::bail!( + "Project directory \"{}\" is not empty.", + target_dir.display() + ); + } + } - let v_major = v_parts.first().copied().unwrap_or(0); - let v_minor = v_parts.get(1).copied().unwrap_or(0); - let v_patch = v_parts.get(2).copied().unwrap_or(0); - let v_build = v_parts.get(3).copied().unwrap_or(0); + // --- Stability inference + validation --- + let (_, minimum_stability) = resolve_stability(stability, package_version.as_deref())?; - let c_major = c_parts.first().copied().unwrap_or(0); - let c_minor = c_parts.get(1).copied().unwrap_or(0); - let c_patch = c_parts.get(2).copied().unwrap_or(0); - let c_build = c_parts.get(3).copied().unwrap_or(0); + // --- Find the best candidate matching constraint + stability --- + let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); + let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); + let files_cache = mozart_registry::cache::Cache::files(&cache_config); - // Must be >= constraint version - let ge = (v_major, v_minor, v_patch, v_build) >= (c_major, c_minor, c_patch, c_build); + let versions = packagist::fetch_package_versions(&name, &repo_cache).await?; - // Upper bound depends on first non-zero segment - let lt = if c_major > 0 { - v_major < c_major + 1 - } else if c_minor > 0 { - v_major == 0 && v_minor < c_minor + 1 + let best = if let Some(ref constraint) = package_version { + versions + .iter() + .filter(|v| version::stability_of(&v.version_normalized) <= minimum_stability) + .filter(|v| version_satisfies_constraint(&v.version, constraint)) + .max_by(|a, b| { + version::compare_normalized_versions(&a.version_normalized, &b.version_normalized) + }) + .ok_or_else(|| { + anyhow::anyhow!("Could not find package {name} with version {constraint}.") + })? } else { - v_major == 0 && v_minor == 0 && v_patch < c_patch + 1 + let stability_label = match minimum_stability { + Stability::Stable => "stable", + Stability::RC => "RC", + Stability::Beta => "beta", + Stability::Alpha => "alpha", + Stability::Dev => "dev", + }; + version::find_best_candidate(&versions, minimum_stability).ok_or_else(|| { + anyhow::anyhow!("Could not find package {name} with stability {stability_label}.") + })? }; - ge && lt -} + let concrete_version = best.version.clone(); -/// Check if a normalized version satisfies a tilde constraint `~MAJOR.MINOR`. -/// -/// Rules: -/// - `~1.2` means `>=1.2.0 <2.0.0` -/// - `~1.2.3` means `>=1.2.3 <1.3.0` -fn tilde_matches(version_normalized: &str, constraint_base: &str) -> bool { - let v_base = if let Some(pos) = version_normalized.find('-') { - &version_normalized[..pos] - } else { - version_normalized - }; + // --- Print "Installing" line + plugin notice --- + console.write_error(&console_format!( + "Installing {name} ({concrete_version})" + )); + if disable_plugins { + console.write_error(&console_format!("Plugins have been disabled.")); + } + + // --- Create the target directory and download + extract the dist archive --- + std::fs::create_dir_all(&target_dir)?; + + let dist = best.dist.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "Package {name} ({concrete_version}) has no dist information — \ + source installs are not yet supported." + ) + })?; - let parse_parts = - |s: &str| -> Vec { s.split('.').filter_map(|p| p.parse().ok()).collect() }; + let mut progress = + downloader::DownloadProgress::new(!no_progress, format!("{name} ({concrete_version})")); - let v_parts = parse_parts(v_base); - let c_parts = parse_parts(constraint_base); + let bytes = downloader::download_dist( + &dist.url, + dist.shasum.as_deref(), + Some(&mut progress), + &files_cache, + ) + .await?; - let v_major = v_parts.first().copied().unwrap_or(0); - let v_minor = v_parts.get(1).copied().unwrap_or(0); - let v_patch = v_parts.get(2).copied().unwrap_or(0); + progress.finish(); - let c_major = c_parts.first().copied().unwrap_or(0); - let c_minor = c_parts.get(1).copied().unwrap_or(0); - let c_patch = c_parts.get(2).copied().unwrap_or(0); + match dist.dist_type.as_str() { + "zip" => downloader::extract_zip(&bytes, &target_dir)?, + "tar" | "tar.gz" | "tgz" => downloader::extract_tar_gz(&bytes, &target_dir)?, + other => anyhow::bail!("Unsupported dist type: {other}"), + } - let ge = if c_parts.len() >= 3 { - (v_major, v_minor, v_patch) >= (c_major, c_minor, c_patch) - } else { - (v_major, v_minor) >= (c_major, c_minor) - }; + // Composer's `installRootPackage` reports `installation_source === 'source'`; + // Mozart only supports dist downloads today, so this is always false. + let installed_from_vcs = false; - let lt = if c_parts.len() >= 3 { - // ~1.2.3 → <1.3.0 - v_major == c_major && v_minor < c_minor + 1 - } else { - // ~1.2 → <2.0 - v_major < c_major + 1 - }; + console.write_error(&console_format!( + "Created project in {}", + target_dir.display() + )); + + // Mirror Composer's `Platform::putEnv('COMPOSER_ROOT_VERSION', ...)` so that + // any subprocesses (or in-process logic) that look up the env var see the + // freshly installed root version. + // SAFETY: setting an env var here races with multi-threaded readers in + // theory, but `create-project` only runs once in process and no concurrent + // env-mutating code exists. + unsafe { + std::env::set_var("COMPOSER_ROOT_VERSION", &concrete_version); + } + + // Also clear `COMPOSER` if a composer.json exists at the new project — the + // env var is meant for the launching project, not the freshly installed one. + if target_dir.join("composer.json").exists() && std::env::var_os("COMPOSER").is_some() { + // SAFETY: see above. + unsafe { + std::env::remove_var("COMPOSER"); + } + } + + let _ = prefer_source; - ge && lt + Ok(InstallRootPackageResult { + installed_from_vcs, + target_dir, + concrete_version, + }) } #[cfg(test)] @@ -727,39 +848,14 @@ mod tests { #[test] fn test_non_empty_directory_rejected() { let dir = tempfile::tempdir().unwrap(); - // Create a file inside so the dir is non-empty std::fs::write(dir.path().join("some-file.txt"), b"content").unwrap(); - - assert!( - is_dir_non_empty(dir.path()), - "Directory with a file should be detected as non-empty" - ); + assert!(is_dir_non_empty(dir.path())); } #[test] fn test_empty_directory_accepted() { let dir = tempfile::tempdir().unwrap(); - assert!( - !is_dir_non_empty(dir.path()), - "Empty directory should not be detected as non-empty" - ); - } - - #[test] - fn test_existing_file_as_directory_rejected() { - let dir = tempfile::tempdir().unwrap(); - let file_path = dir.path().join("myfile"); - std::fs::write(&file_path, b"data").unwrap(); - - // Verify that is_file() returns true (so the execute() function would bail) - assert!( - file_path.is_file(), - "A created file should be detected as a file, not a directory" - ); - assert!( - !file_path.is_dir(), - "A regular file should not be detected as a directory" - ); + assert!(!is_dir_non_empty(dir.path())); } #[test] @@ -800,60 +896,84 @@ mod tests { } #[test] - fn test_version_matches_caret() { - assert!(version_matches_constraint("1.2.0", "1.2.0.0", "^1.0")); - assert!(version_matches_constraint("1.9.9", "1.9.9.0", "^1.0")); - assert!(!version_matches_constraint("2.0.0", "2.0.0.0", "^1.0")); - assert!(!version_matches_constraint("0.9.0", "0.9.0.0", "^1.0")); + fn test_resolve_stability_explicit() { + let (s, e) = resolve_stability(Some("dev"), None).unwrap(); + assert_eq!(s, "dev"); + assert_eq!(e, Stability::Dev); + + let (s, e) = resolve_stability(Some("RC"), None).unwrap(); + assert_eq!(s, "RC"); + assert_eq!(e, Stability::RC); + + // case-insensitive + let (s, _) = resolve_stability(Some("BETA"), None).unwrap(); + assert_eq!(s, "beta"); + } + + #[test] + fn test_resolve_stability_invalid() { + let err = resolve_stability(Some("garbage"), None).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("Invalid stability provided (garbage)")); + assert!(msg.contains("must be one of")); } #[test] - fn test_version_matches_exact() { - assert!(version_matches_constraint("1.2.3", "1.2.3.0", "1.2.3")); - assert!(!version_matches_constraint("1.2.4", "1.2.4.0", "1.2.3")); + fn test_resolve_stability_default() { + let (s, e) = resolve_stability(None, None).unwrap(); + assert_eq!(s, "stable"); + assert_eq!(e, Stability::Stable); } #[test] - fn test_version_matches_gte() { - assert!(version_matches_constraint("1.2.0", "1.2.0.0", ">=1.0.0")); - assert!(version_matches_constraint("2.0.0", "2.0.0.0", ">=1.0.0")); - assert!(!version_matches_constraint("0.9.0", "0.9.0.0", ">=1.0.0")); + fn test_resolve_stability_from_at_suffix() { + let (s, e) = resolve_stability(None, Some("^2.0@beta")).unwrap(); + assert_eq!(s, "beta"); + assert_eq!(e, Stability::Beta); + + let (s, _) = resolve_stability(None, Some("1.0.0@dev")).unwrap(); + assert_eq!(s, "dev"); } #[test] - fn test_version_matches_stability_flag() { - // "@beta" suffix in constraint should be stripped for matching - assert!(version_matches_constraint("2.0.0", "2.0.0.0", "^2.0@beta")); + fn test_resolve_stability_from_version_suffix() { + let (s, _) = resolve_stability(None, Some("1.0.0-beta1")).unwrap(); + assert_eq!(s, "beta"); + let (s, _) = resolve_stability(None, Some("dev-master")).unwrap(); + assert_eq!(s, "dev"); + let (s, _) = resolve_stability(None, Some("1.0.0")).unwrap(); + assert_eq!(s, "stable"); } #[test] - fn test_caret_matches() { - // ^1.0 → >=1.0.0.0 <2.0.0.0 - assert!(caret_matches("1.0.0.0", "1.0")); - assert!(caret_matches("1.9.9.0", "1.0")); - assert!(!caret_matches("2.0.0.0", "1.0")); - assert!(!caret_matches("0.9.9.0", "1.0")); + fn test_version_satisfies_constraint_via_semver() { + assert!(version_satisfies_constraint("1.2.0", "^1.0")); + assert!(version_satisfies_constraint("1.9.9", "^1.0")); + assert!(!version_satisfies_constraint("2.0.0", "^1.0")); + assert!(!version_satisfies_constraint("0.9.0", "^1.0")); + + assert!(version_satisfies_constraint("1.2.3", "1.2.3")); + assert!(!version_satisfies_constraint("1.2.4", "1.2.3")); - // ^0.3 → >=0.3.0.0 <0.4.0.0 - assert!(caret_matches("0.3.0.0", "0.3")); - assert!(caret_matches("0.3.9.0", "0.3")); - assert!(!caret_matches("0.4.0.0", "0.3")); + assert!(version_satisfies_constraint("1.2.0", ">=1.0.0")); + assert!(version_satisfies_constraint("2.0.0", ">=1.0.0")); + assert!(!version_satisfies_constraint("0.9.0", ">=1.0.0")); - // ^0.0.3 → >=0.0.3.0 <0.0.4.0 - assert!(caret_matches("0.0.3.0", "0.0.3")); - assert!(!caret_matches("0.0.4.0", "0.0.3")); + // Stability flag attached to the constraint should not break parsing. + assert!(version_satisfies_constraint("2.0.0", "^2.0@beta")); + } + + #[test] + fn test_shortest_path_inside_cwd() { + let cwd = PathBuf::from("/home/me/projects"); + let dir = cwd.join("foo"); + assert_eq!(shortest_path(&cwd, &dir), "foo"); } #[test] - fn test_tilde_matches() { - // ~1.2 → >=1.2 <2.0 - assert!(tilde_matches("1.2.0.0", "1.2")); - assert!(tilde_matches("1.9.9.0", "1.2")); - assert!(!tilde_matches("2.0.0.0", "1.2")); - - // ~1.2.3 → >=1.2.3 <1.3.0 - assert!(tilde_matches("1.2.3.0", "1.2.3")); - assert!(tilde_matches("1.2.9.0", "1.2.3")); - assert!(!tilde_matches("1.3.0.0", "1.2.3")); + fn test_shortest_path_outside_cwd() { + let cwd = PathBuf::from("/home/me/projects"); + let dir = PathBuf::from("/elsewhere/bar"); + assert_eq!(shortest_path(&cwd, &dir), "/elsewhere/bar"); } } -- cgit v1.3.1