aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/mozart/src')
-rw-r--r--crates/mozart/src/commands/create_project.rs711
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"));
+ }
}