diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index dbfecf140b57d..3a76620d47f37 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -1009,8 +1009,12 @@ pub fn add_dependency( enum Sort { /// The list is sorted in a case-insensitive manner. CaseInsensitive, + /// The list is sorted naively in a case-insensitive manner. + CaseInsensitiveNaive, /// The list is sorted in a case-sensitive manner. CaseSensitive, + /// The list is sorted naively in a case-sensitive manner. + CaseSensitiveNaive, /// The list is unsorted. Unsorted, } @@ -1029,6 +1033,13 @@ pub fn add_dependency( ) } + /// Naively compare two [`Value`] requirements case-insensitively. + fn case_insensitive_naive(a: &Value, b: &Value) -> Ordering { + a.as_str() + .map(str::to_lowercase) + .cmp(&b.as_str().map(str::to_lowercase)) + } + /// Compare two [`Value`] requirements case-sensitively. fn case_sensitive(a: &Value, b: &Value) -> Ordering { a.as_str() @@ -1036,6 +1047,11 @@ pub fn add_dependency( .cmp(&b.as_str().map(split_specifiers)) } + /// Naively compare two [`Value`] requirements case-sensitively. + fn case_sensitive_naive(a: &Value, b: &Value) -> Ordering { + a.as_str().cmp(&b.as_str()) + } + // Determine if the dependency list is sorted prior to // adding the new dependency; the new dependency list // will be sorted only when the original list is sorted @@ -1057,6 +1073,17 @@ pub fn add_dependency( matches!(case_sensitive(a, b), Ordering::Less | Ordering::Equal) }) { Some(Sort::CaseSensitive) + } else if deps.iter().tuple_windows().all(|(a, b)| { + matches!( + case_insensitive_naive(a, b), + Ordering::Less | Ordering::Equal + ) + }) { + Some(Sort::CaseInsensitiveNaive) + } else if deps.iter().tuple_windows().all(|(a, b)| { + matches!(case_sensitive_naive(a, b), Ordering::Less | Ordering::Equal) + }) { + Some(Sort::CaseSensitiveNaive) } else { None } @@ -1069,9 +1096,16 @@ pub fn add_dependency( Sort::CaseInsensitive => deps.iter().position(|d| { case_insensitive(d, &Value::from(req_string.as_str())) == Ordering::Greater }), + Sort::CaseInsensitiveNaive => deps.iter().position(|d| { + case_insensitive_naive(d, &Value::from(req_string.as_str())) + == Ordering::Greater + }), Sort::CaseSensitive => deps.iter().position(|d| { case_sensitive(d, &Value::from(req_string.as_str())) == Ordering::Greater }), + Sort::CaseSensitiveNaive => deps.iter().position(|d| { + case_sensitive_naive(d, &Value::from(req_string.as_str())) == Ordering::Greater + }), Sort::Unsorted => None, }; let index = index.unwrap_or(deps.len()); diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index d9b39a7e93ba5..160bbf4b79560 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -7240,6 +7240,63 @@ fn sorted_dependencies() -> Result<()> { Ok(()) } +/// Ensure that if the dependencies are sorted naively (i.e. by the whole +/// requirement specifier), that added dependencies are sorted in the same way. +#[test] +fn naive_sorted_dependencies() -> Result<()> { + let context = TestContext::new("3.12").with_filtered_counts(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "pytest-mock>=3.14", + "pytest>=8.1.1", + ] + "#})?; + + uv_snapshot!(context.filters(), context.add().args(["pytest-randomly"]), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + iniconfig==2.0.0 + + packaging==24.0 + + pluggy==1.4.0 + + pytest==8.1.1 + + pytest-mock==3.14.0 + + pytest-randomly==3.15.0 + "); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "pytest-mock>=3.14", + "pytest-randomly>=3.15.0", + "pytest>=8.1.1", + ] + "### + ); + }); + Ok(()) +} + /// Ensure that the added dependencies are case sensitive sorted if the dependency list was already /// case sensitive sorted prior to the operation. #[test] @@ -7307,6 +7364,66 @@ fn case_sensitive_sorted_dependencies() -> Result<()> { Ok(()) } +/// Ensure that if the dependencies are sorted naively (i.e. by the whole +/// requirement specifier), that added dependencies are sorted in the same way. +#[test] +fn case_sensitive_naive_sorted_dependencies() -> Result<()> { + let context = TestContext::new("3.12").with_filtered_counts(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "Typing-extensions>=4.10.0", + "pytest-mock>=3.14", + "pytest>=8.1.1", + ] + "#})?; + + uv_snapshot!(context.filters(), context.add().args(["pytest-randomly"]), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + iniconfig==2.0.0 + + packaging==24.0 + + pluggy==1.4.0 + + pytest==8.1.1 + + pytest-mock==3.14.0 + + pytest-randomly==3.15.0 + + typing-extensions==4.10.0 + "); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "Typing-extensions>=4.10.0", + "pytest-mock>=3.14", + "pytest-randomly>=3.15.0", + "pytest>=8.1.1", + ] + "### + ); + }); + Ok(()) +} + /// Ensure that sorting is based on the name, rather than the combined name-and-specifiers. #[test] fn sorted_dependencies_name_specifiers() -> Result<()> {