aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-08 21:33:52 +0900
committernsfisis <nsfisis@gmail.com>2026-05-08 21:33:52 +0900
commit6d853db4e74f07abe480ab9532c914ba94623dc0 (patch)
treee46ba04a9633cd3131a01e443698c2cfc2b15441 /crates/mozart
parentf20a342ecb96734418d0817f841ea14fd9a448e3 (diff)
downloadphp-mozart-6d853db4e74f07abe480ab9532c914ba94623dc0.tar.gz
php-mozart-6d853db4e74f07abe480ab9532c914ba94623dc0.tar.zst
php-mozart-6d853db4e74f07abe480ab9532c914ba94623dc0.zip
fix(create-project): align with Composer's CreateProjectCommand pipeline
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.
Diffstat (limited to 'crates/mozart')
-rw-r--r--crates/mozart/src/commands/create_project.rs850
1 files changed, 485 insertions, 365 deletions
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!(
- "<warning>Source installs not yet supported, falling back to dist.</warning>"
- ));
- }
-
- if args.dev {
- console.info(&console_format!(
- "<warning>The --dev flag is deprecated. Dev packages are installed by default.</warning>"
- ));
- }
-
- if args.no_custom_installers {
- console.info(&console_format!(
- "<warning>The --no-custom-installers flag is deprecated. Use --no-plugins instead.</warning>"
- ));
- }
-
- if !args.repository.is_empty() || args.repository_url.is_some() || args.add_repository {
- console.info(&console_format!(
- "<warning>Custom repository options (--repository, --repository-url, --add-repository) \
- are not yet supported and will be ignored.</warning>"
- ));
+/// 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<String> = 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!(
+ "<warning>You are using the deprecated option \"dev\". Dev packages are installed by default now.</warning>"
+ ));
}
- 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!(
+ "<warning>You are using the deprecated option \"no-custom-installers\". Use \"no-plugins\" instead.</warning>"
+ ));
}
- // --- 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<String> = 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 [<comment>{basename}</comment>]: "),
+ &basename,
+ );
+ Some(answer)
} else {
- Stability::Stable
+ args.directory.clone()
};
- // --- Step 4: Fetch package versions and find best match ---
- console.info(&console_format!(
- "<info>Creating project from package {package_name}</info>"
- ));
- 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<Vec<String>> ---
+ let repositories: Option<Vec<String>> = 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!(
- "<info>Installing {package_name} ({concrete_version})</info>"
- ));
-
- // --- 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<Vec<String>>,
+ 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!(
+ "<warning>Custom repository options (--repository, --repository-url, --add-repository) \
+ are not yet supported and will be ignored.</warning>"
+ ));
}
- console.info(&console_format!(
- "<info>Created project in {}</info>",
- 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!(
+ "<info>Do you want to remove the existing VCS (.git, .svn..) history?</info> [<comment>y,n</comment>]? "
+ ))
+ } 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!(
"<warning>No composer.json found in {}. Skipping dependency installation.</warning>",
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!(
"<comment>Skipping dependency installation (--no-install).</comment>"
));
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!(
"<warning>Source installs are not yet supported. Falling back to dist.</warning>"
));
}
@@ -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<InstallRootPackageResult> {
+ 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<String> = 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)
-}
+ let short = shortest_path(&working_dir, &target_dir);
+ console.write_error(&console_format!(
+ "<info>Creating a \"{package_name}\" project at \"{short}\"</info>"
+ ));
+
+ 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()
+ );
+ }
+ }
+
+ // --- Stability inference + validation ---
+ let (_, minimum_stability) = resolve_stability(stability, package_version.as_deref())?;
+
+ // --- 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);
+
+ let versions = packagist::fetch_package_versions(&name, &repo_cache).await?;
-/// 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]
+ 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 {
- version_normalized
+ 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}.")
+ })?
};
- let parse_parts =
- |s: &str| -> Vec<u64> { s.split('.').filter_map(|p| p.parse().ok()).collect() };
-
- let v_parts = parse_parts(v_base);
- let c_parts = parse_parts(constraint_base);
+ let concrete_version = best.version.clone();
- 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);
+ // --- Print "Installing" line + plugin notice ---
+ console.write_error(&console_format!(
+ "<info>Installing {name} ({concrete_version})</info>"
+ ));
+ if disable_plugins {
+ console.write_error(&console_format!("<info>Plugins have been disabled.</info>"));
+ }
- 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);
+ // --- Create the target directory and download + extract the dist archive ---
+ std::fs::create_dir_all(&target_dir)?;
- // Must be >= constraint version
- let ge = (v_major, v_minor, v_patch, v_build) >= (c_major, c_minor, c_patch, c_build);
+ 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."
+ )
+ })?;
- // 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
- } else {
- v_major == 0 && v_minor == 0 && v_patch < c_patch + 1
- };
+ let mut progress =
+ downloader::DownloadProgress::new(!no_progress, format!("{name} ({concrete_version})"));
- ge && lt
-}
+ let bytes = downloader::download_dist(
+ &dist.url,
+ dist.shasum.as_deref(),
+ Some(&mut progress),
+ &files_cache,
+ )
+ .await?;
-/// 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
- };
+ progress.finish();
- let parse_parts =
- |s: &str| -> Vec<u64> { s.split('.').filter_map(|p| p.parse().ok()).collect() };
+ 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 v_parts = parse_parts(v_base);
- let c_parts = parse_parts(constraint_base);
+ // Composer's `installRootPackage` reports `installation_source === 'source'`;
+ // Mozart only supports dist downloads today, so this is always false.
+ let installed_from_vcs = false;
- 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);
+ console.write_error(&console_format!(
+ "<info>Created project in {}</info>",
+ target_dir.display()
+ ));
- 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);
+ // 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);
+ }
- 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)
- };
+ // 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 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
- };
+ 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"));
- // ^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.3", "1.2.3"));
+ assert!(!version_satisfies_constraint("1.2.4", "1.2.3"));
- // ^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"));
+ 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"));
+
+ // Stability flag attached to the constraint should not break parsing.
+ assert!(version_satisfies_constraint("2.0.0", "^2.0@beta"));
}
#[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"));
+ 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");
+ }
- // ~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"));
+ #[test]
+ 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");
}
}