diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-21 18:51:54 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-21 18:51:54 +0900 |
| commit | 727b75d20ee54563fe0f1619341fd4cfaf814211 (patch) | |
| tree | e3abc89a8799acb042b70d8c724df191c22a4fa6 /crates/mozart/src/commands/create_project.rs | |
| parent | 6d22ea76e12cbf0c6e1b873d3350d1bfad2c5442 (diff) | |
| download | php-mozart-727b75d20ee54563fe0f1619341fd4cfaf814211.tar.gz php-mozart-727b75d20ee54563fe0f1619341fd4cfaf814211.tar.zst php-mozart-727b75d20ee54563fe0f1619341fd4cfaf814211.zip | |
feat(create-project): implement create-project command to scaffold new projects from packages
Replaces the todo\!() stub with a full implementation that parses the
package argument, resolves the best matching version from Packagist,
downloads and extracts the dist archive into a target directory, removes
VCS metadata, replaces self.version constraints, and runs dependency
resolution and installation via the existing resolver and install
infrastructure. Includes version constraint matching (caret, tilde,
wildcard, comparison operators) and comprehensive unit tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart/src/commands/create_project.rs')
| -rw-r--r-- | crates/mozart/src/commands/create_project.rs | 711 |
1 files changed, 709 insertions, 2 deletions
diff --git a/crates/mozart/src/commands/create_project.rs b/crates/mozart/src/commands/create_project.rs index e8339d0..fabf7b4 100644 --- a/crates/mozart/src/commands/create_project.rs +++ b/crates/mozart/src/commands/create_project.rs @@ -1,4 +1,14 @@ +use crate::console; +use crate::downloader; +use crate::lockfile; +use crate::package::{self, Stability}; +use crate::packagist; +use crate::resolver::{self, PlatformConfig, ResolveRequest}; +use crate::validation; +use crate::version; use clap::Args; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; #[derive(Args)] pub struct CreateProjectArgs { @@ -100,6 +110,703 @@ pub struct CreateProjectArgs { pub ask: bool, } -pub fn execute(_args: &CreateProjectArgs, _cli: &super::Cli) -> anyhow::Result<()> { - todo!() +/// VCS metadata directories to remove. +const VCS_DIRS: &[&str] = &[ + ".git", + ".svn", + "_svn", + "CVS", + "_darcs", + ".arch-params", + ".monotone", + ".bzr", + ".hg", + ".fslckout", + "_FOSSIL_", +]; + +/// 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('/') { + &package_name[slash + 1..] + } else { + package_name + } +} + +/// Remove VCS metadata directories from the target directory. +fn remove_vcs_metadata(target_dir: &Path) -> anyhow::Result<()> { + for vcs_dir in VCS_DIRS { + let path = target_dir.join(vcs_dir); + if path.exists() { + std::fs::remove_dir_all(&path)?; + eprintln!( + "{}", + console::comment(&format!("Removed VCS metadata directory: {vcs_dir}")) + ); + } + } + Ok(()) +} + +/// Replace "self.version" constraints in a composer.json with a concrete version string. +fn replace_self_version(raw: &mut package::RawPackageData, concrete_version: &str) { + for value in raw.require.values_mut() { + if value == "self.version" { + *value = concrete_version.to_string(); + } + } + for value in raw.require_dev.values_mut() { + if value == "self.version" { + *value = concrete_version.to_string(); + } + } +} + +/// Check if a directory is non-empty (has any contents). +fn is_dir_non_empty(path: &Path) -> bool { + std::fs::read_dir(path) + .map(|mut d| d.next().is_some()) + .unwrap_or(false) +} + +pub fn execute(args: &CreateProjectArgs, cli: &super::Cli) -> anyhow::Result<()> { + // --- Handle deprecated / no-op flags --- + if args.prefer_source { + eprintln!( + "{}", + console::warning("Source installs not yet supported, falling back to dist.") + ); + } + + if args.dev { + eprintln!( + "{}", + console::warning( + "The --dev flag is deprecated. Dev packages are installed by default." + ) + ); + } + + if args.no_custom_installers { + eprintln!( + "{}", + console::warning( + "The --no-custom-installers flag is deprecated. Use --no-plugins instead." + ) + ); + } + + if !args.repository.is_empty() || args.repository_url.is_some() || args.add_repository { + eprintln!( + "{}", + console::warning( + "Custom repository options (--repository, --repository-url, --add-repository) \ + are not yet supported and will be ignored." + ) + ); + } + + // --- Step 1: Parse package argument --- + let package_arg = match &args.package { + Some(p) => p.clone(), + None => anyhow::bail!("Not enough arguments (missing: \"package\")."), + }; + + // 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), + }; + + // 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()); + + // --- Step 2: Determine target directory --- + let working_dir = super::install::resolve_working_dir(cli); + + 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) + } + }; + + // Validate target directory + if target_dir.is_file() { + anyhow::bail!( + "Target directory \"{}\" exists as a file.", + target_dir.display() + ); + } + if target_dir.is_dir() && is_dir_non_empty(&target_dir) { + anyhow::bail!( + "Target directory \"{}\" is not empty.", + target_dir.display() + ); + } + + // --- 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) + } else { + Stability::Stable + }; + + // --- Step 4: Fetch package versions and find best match --- + eprintln!( + "{}", + console::info(&format!("Creating project from package {package_name}")) + ); + eprintln!("Loading composer repositories with package information"); + + let versions = packagist::fetch_package_versions(&package_name, None)?; + + // 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:?})." + ) + })? + } 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:?})." + ) + })? + }; + + let concrete_version = best.version.clone(); + + eprintln!( + "{}", + console::info(&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 bytes = + downloader::download_dist(&dist.url, dist.shasum.as_deref(), Some(&mut progress), None)?; + + progress.finish(); + + 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}"), + } + + eprintln!( + "{}", + console::info(&format!("Created project in {}", target_dir.display())) + ); + + // --- 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. + if args.remove_vcs || !args.keep_vcs { + remove_vcs_metadata(&target_dir)?; + } + + // --- Step 6: Read composer.json and optionally install dependencies --- + let composer_path = target_dir.join("composer.json"); + + if !composer_path.exists() { + eprintln!( + "{}", + console::warning(&format!( + "No composer.json found in {}. Skipping dependency installation.", + target_dir.display() + )) + ); + return Ok(()); + } + + let mut raw = package::read_from_file(&composer_path)?; + + // --- Step 8: Replace self.version constraints --- + 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 { + eprintln!( + "{}", + console::comment("Skipping dependency installation (--no-install).") + ); + return Ok(()); + } + + let dev_mode = !args.no_dev; + + let require: Vec<(String, String)> = raw + .require + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + let require_dev: Vec<(String, String)> = raw + .require_dev + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + let proj_minimum_stability_str = raw.minimum_stability.as_deref().unwrap_or("stable"); + let proj_minimum_stability = Stability::parse(proj_minimum_stability_str); + + let composer_prefer_stable = raw + .extra_fields + .get("prefer-stable") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let request = ResolveRequest { + require, + require_dev, + include_dev: dev_mode, + minimum_stability: proj_minimum_stability, + stability_flags: HashMap::new(), + prefer_stable: composer_prefer_stable, + prefer_lowest: false, + platform: PlatformConfig::new(), + ignore_platform_reqs: args.ignore_platform_reqs, + ignore_platform_req_list: args.ignore_platform_req.clone(), + repo_cache: None, + }; + + eprintln!("Resolving dependencies..."); + + let resolved = match resolver::resolve(&request) { + Ok(packages) => packages, + Err(e) => { + eprintln!("{}", console::error(&e.to_string())); + std::process::exit(1); + } + }; + + let composer_json_content = std::fs::read_to_string(&composer_path)?; + + let new_lock = lockfile::generate_lock_file(&lockfile::LockFileGenerationRequest { + resolved_packages: resolved, + composer_json_content: composer_json_content.clone(), + composer_json: raw.clone(), + include_dev: dev_mode, + repo_cache: None, + })?; + + // 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 + .iter() + .filter(|c| matches!(c.kind, super::update::ChangeKind::Install { .. })) + .collect(); + + eprintln!( + "{}", + console::info(&format!( + "Package operations: {} install{}, 0 updates, 0 removals", + installs.len(), + if installs.len() == 1 { "" } else { "s" }, + )) + ); + + for change in &changes { + if let super::update::ChangeKind::Install { new_version } = &change.kind { + eprintln!(" - Installing {} ({})", change.name, new_version); + } + } + + eprintln!("Writing lock file"); + let lock_path = target_dir.join("composer.lock"); + new_lock.write_to_file(&lock_path)?; + + 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 { + eprintln!( + "{}", + console::warning("Source installs are not yet supported. Falling back to dist.") + ); + } + + super::install::install_from_lock( + &new_lock, + &target_dir, + &vendor_dir, + &super::install::InstallConfig { + dev_mode, + dry_run: false, + no_autoloader: false, + no_progress: args.no_progress, + ignore_platform_reqs: args.ignore_platform_reqs, + ignore_platform_req: args.ignore_platform_req.clone(), + optimize_autoloader: false, + classmap_authoritative: false, + apcu_autoloader: false, + apcu_autoloader_prefix: None, + }, + )?; + + 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 + }; + + let constraint = constraint.trim(); + + // Handle dev-branch constraints + if constraint.starts_with("dev-") { + return version == constraint || version_normalized == constraint; + } + + // 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); + } + + // 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, + }; + } + } + + // 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); + } + + // 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); + } + + // 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<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 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); + + 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); + + // Must be >= constraint version + let ge = (v_major, v_minor, v_patch, v_build) >= (c_major, c_minor, c_patch, c_build); + + // 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 + }; + + ge && lt +} + +/// 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 + }; + + 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 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 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 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) + }; + + 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 + }; + + ge && lt +} + +#[cfg(test)] +mod tests { + use super::*; + + // ───────────────────────────────────────────────────────────────────────── + // dir_from_package_name tests + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn test_directory_from_package_name() { + assert_eq!(dir_from_package_name("vendor/package"), "package"); + assert_eq!(dir_from_package_name("monolog/monolog"), "monolog"); + assert_eq!(dir_from_package_name("symfony/console"), "console"); + // No slash: use entire string + assert_eq!(dir_from_package_name("novendor"), "novendor"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Target directory validation 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" + ); + } + + #[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" + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // self.version replacement tests + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn test_self_version_replacement() { + let mut raw = package::RawPackageData::new("vendor/pkg".to_string()); + raw.require + .insert("vendor/dep-a".to_string(), "self.version".to_string()); + raw.require + .insert("vendor/dep-b".to_string(), "^1.0".to_string()); + raw.require_dev + .insert("vendor/dep-c".to_string(), "self.version".to_string()); + + replace_self_version(&mut raw, "2.3.4"); + + assert_eq!(raw.require.get("vendor/dep-a").unwrap(), "2.3.4"); + assert_eq!(raw.require.get("vendor/dep-b").unwrap(), "^1.0"); + assert_eq!(raw.require_dev.get("vendor/dep-c").unwrap(), "2.3.4"); + } + + #[test] + fn test_self_version_replacement_no_self_version() { + let mut raw = package::RawPackageData::new("vendor/pkg".to_string()); + raw.require + .insert("vendor/dep-a".to_string(), "^1.0".to_string()); + + replace_self_version(&mut raw, "2.3.4"); + + assert_eq!(raw.require.get("vendor/dep-a").unwrap(), "^1.0"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Version constraint matching 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")); + } + + #[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")); + } + + #[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")); + } + + #[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")); + } + + #[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")); + + // ^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")); + + // ^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")); + } + + #[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")); + } } |
