1use std::fmt::{Display, Formatter};
2use std::path::Path;
3use std::str::FromStr;
4use std::{fmt, iter, mem};
5
6use itertools::Itertools;
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9use toml_edit::{
10 Array, ArrayOfTables, DocumentMut, Formatted, Item, RawString, Table, TomlError, Value,
11};
12
13use uv_cache_key::CanonicalUrl;
14use uv_distribution_types::Index;
15use uv_fs::PortablePath;
16use uv_normalize::{ExtraName, GroupName, PackageName};
17use uv_pep440::{Version, VersionParseError, VersionSpecifier, VersionSpecifiers};
18use uv_pep508::{MarkerTree, Requirement, VersionOrUrl};
19use uv_redacted::DisplaySafeUrl;
20
21use crate::pyproject::{DependencyType, Source};
22
23pub struct PyProjectTomlMut {
28 doc: DocumentMut,
29 target: DependencyTarget,
30}
31
32#[derive(Error, Debug)]
33pub enum Error {
34 #[error("Failed to parse `pyproject.toml`")]
35 Parse(#[from] Box<TomlError>),
36 #[error("Failed to serialize `pyproject.toml`")]
37 Serialize(#[from] Box<toml::ser::Error>),
38 #[error("Failed to deserialize `pyproject.toml`")]
39 Deserialize(#[from] Box<toml::de::Error>),
40 #[error("Dependencies in `pyproject.toml` are malformed")]
41 MalformedDependencies,
42 #[error("Sources in `pyproject.toml` are malformed")]
43 MalformedSources,
44 #[error("Workspace in `pyproject.toml` is malformed")]
45 MalformedWorkspace,
46 #[error("Expected a dependency at index {0}")]
47 MissingDependency(usize),
48 #[error("Failed to parse `version` field of `pyproject.toml`")]
49 VersionParse(#[from] VersionParseError),
50 #[error("Cannot perform ambiguous update; found multiple entries for `{}`:\n{}", package_name, requirements.iter().map(|requirement| format!("- `{requirement}`")).join("\n"))]
51 Ambiguous {
52 package_name: PackageName,
53 requirements: Vec<Requirement>,
54 },
55 #[error("Unknown bound king {0}")]
56 UnknownBoundKind(String),
57}
58
59#[derive(Debug, Copy, Clone, PartialEq, Eq)]
61pub enum ArrayEdit {
62 Update(usize),
64 Add(usize),
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69enum CommentType {
70 OwnLine,
72 EndOfLine { leading_whitespace: String },
74}
75
76#[derive(Debug, Clone)]
77struct Comment {
78 text: String,
79 kind: CommentType,
80}
81
82impl ArrayEdit {
83 pub fn index(&self) -> usize {
84 match self {
85 Self::Update(i) | Self::Add(i) => *i,
86 }
87 }
88}
89
90#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
95#[serde(rename_all = "kebab-case")]
96#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
97#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
98pub enum AddBoundsKind {
99 #[default]
101 Lower,
102 Major,
106 Minor,
110 Exact,
114}
115
116impl Display for AddBoundsKind {
117 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
118 match self {
119 Self::Lower => write!(f, "lower"),
120 Self::Major => write!(f, "major"),
121 Self::Minor => write!(f, "minor"),
122 Self::Exact => write!(f, "exact"),
123 }
124 }
125}
126
127impl AddBoundsKind {
128 fn specifiers(self, version: Version) -> VersionSpecifiers {
129 match self {
133 Self::Lower => {
134 VersionSpecifiers::from(VersionSpecifier::greater_than_equal_version(version))
135 }
136 Self::Major => {
137 let leading_zeroes = version
138 .release()
139 .iter()
140 .take_while(|digit| **digit == 0)
141 .count();
142
143 if leading_zeroes == version.release().len() {
145 let upper_bound = Version::new(
146 [0, 1]
147 .into_iter()
148 .chain(iter::repeat_n(0, version.release().iter().skip(2).len())),
149 );
150 return VersionSpecifiers::from_iter([
151 VersionSpecifier::greater_than_equal_version(version),
152 VersionSpecifier::less_than_version(upper_bound),
153 ]);
154 }
155
156 let major = version.release().get(leading_zeroes).copied().unwrap_or(0);
164 let trailing_zeros = version.release().iter().skip(leading_zeroes + 1).len();
166 let upper_bound = Version::new(
167 iter::repeat_n(0, leading_zeroes)
168 .chain(iter::once(major + 1))
169 .chain(iter::repeat_n(0, trailing_zeros)),
170 );
171
172 VersionSpecifiers::from_iter([
173 VersionSpecifier::greater_than_equal_version(version),
174 VersionSpecifier::less_than_version(upper_bound),
175 ])
176 }
177 Self::Minor => {
178 let leading_zeroes = version
179 .release()
180 .iter()
181 .take_while(|digit| **digit == 0)
182 .count();
183
184 if leading_zeroes == version.release().len() {
186 let upper_bound = [0, 0, 1]
187 .into_iter()
188 .chain(iter::repeat_n(0, version.release().iter().skip(3).len()));
189 return VersionSpecifiers::from_iter([
190 VersionSpecifier::greater_than_equal_version(version),
191 VersionSpecifier::less_than_version(Version::new(upper_bound)),
192 ]);
193 }
194
195 if leading_zeroes >= 2 {
200 let most_significant =
201 version.release().get(leading_zeroes).copied().unwrap_or(0);
202 let trailing_zeros = version.release().iter().skip(leading_zeroes + 1).len();
204 let upper_bound = Version::new(
205 iter::repeat_n(0, leading_zeroes)
206 .chain(iter::once(most_significant + 1))
207 .chain(iter::repeat_n(0, trailing_zeros)),
208 );
209 return VersionSpecifiers::from_iter([
210 VersionSpecifier::greater_than_equal_version(version),
211 VersionSpecifier::less_than_version(upper_bound),
212 ]);
213 }
214
215 let major = version.release().get(leading_zeroes).copied().unwrap_or(0);
226 let minor = version
227 .release()
228 .get(leading_zeroes + 1)
229 .copied()
230 .unwrap_or(0);
231 let upper_bound = Version::new(
232 iter::repeat_n(0, leading_zeroes)
233 .chain(iter::once(major))
234 .chain(iter::once(minor + 1))
235 .chain(iter::repeat_n(
236 0,
237 version.release().iter().skip(leading_zeroes + 2).len(),
238 )),
239 );
240
241 VersionSpecifiers::from_iter([
242 VersionSpecifier::greater_than_equal_version(version),
243 VersionSpecifier::less_than_version(upper_bound),
244 ])
245 }
246 Self::Exact => {
247 VersionSpecifiers::from_iter([VersionSpecifier::equals_version(version)])
248 }
249 }
250 }
251}
252
253#[derive(Debug, Copy, Clone, PartialEq, Eq)]
255pub enum DependencyTarget {
256 Script,
258 PyProjectToml,
260}
261
262impl PyProjectTomlMut {
263 pub fn from_toml(raw: &str, target: DependencyTarget) -> Result<Self, Error> {
265 Ok(Self {
266 doc: raw.parse().map_err(Box::new)?,
267 target,
268 })
269 }
270
271 pub fn add_workspace(&mut self, path: impl AsRef<Path>) -> Result<(), Error> {
273 let members = self
275 .doc
276 .entry("tool")
277 .or_insert(implicit())
278 .as_table_mut()
279 .ok_or(Error::MalformedWorkspace)?
280 .entry("uv")
281 .or_insert(implicit())
282 .as_table_mut()
283 .ok_or(Error::MalformedWorkspace)?
284 .entry("workspace")
285 .or_insert(Item::Table(Table::new()))
286 .as_table_mut()
287 .ok_or(Error::MalformedWorkspace)?
288 .entry("members")
289 .or_insert(Item::Value(Value::Array(Array::new())))
290 .as_array_mut()
291 .ok_or(Error::MalformedWorkspace)?;
292
293 members.push(PortablePath::from(path.as_ref()).to_string());
295
296 reformat_array_multiline(members);
297
298 Ok(())
299 }
300
301 fn project(&mut self) -> Result<&mut Table, Error> {
306 let doc = match self.target {
307 DependencyTarget::Script => self.doc.as_table_mut(),
308 DependencyTarget::PyProjectToml => self
309 .doc
310 .entry("project")
311 .or_insert(Item::Table(Table::new()))
312 .as_table_mut()
313 .ok_or(Error::MalformedDependencies)?,
314 };
315 Ok(doc)
316 }
317
318 fn project_mut(&mut self) -> Result<Option<&mut Table>, Error> {
323 let doc = match self.target {
324 DependencyTarget::Script => Some(self.doc.as_table_mut()),
325 DependencyTarget::PyProjectToml => self
326 .doc
327 .get_mut("project")
328 .map(|project| project.as_table_mut().ok_or(Error::MalformedSources))
329 .transpose()?,
330 };
331 Ok(doc)
332 }
333
334 pub fn add_dependency(
338 &mut self,
339 req: &Requirement,
340 source: Option<&Source>,
341 raw: bool,
342 ) -> Result<ArrayEdit, Error> {
343 let dependencies = self
345 .project()?
346 .entry("dependencies")
347 .or_insert(Item::Value(Value::Array(Array::new())))
348 .as_array_mut()
349 .ok_or(Error::MalformedDependencies)?;
350
351 let edit = add_dependency(req, dependencies, source.is_some(), raw)?;
352
353 if let Some(source) = source {
354 self.add_source(&req.name, source)?;
355 }
356
357 Ok(edit)
358 }
359
360 pub fn add_dev_dependency(
364 &mut self,
365 req: &Requirement,
366 source: Option<&Source>,
367 raw: bool,
368 ) -> Result<ArrayEdit, Error> {
369 let dev_dependencies = self
371 .doc
372 .entry("tool")
373 .or_insert(implicit())
374 .as_table_mut()
375 .ok_or(Error::MalformedSources)?
376 .entry("uv")
377 .or_insert(Item::Table(Table::new()))
378 .as_table_mut()
379 .ok_or(Error::MalformedSources)?
380 .entry("dev-dependencies")
381 .or_insert(Item::Value(Value::Array(Array::new())))
382 .as_array_mut()
383 .ok_or(Error::MalformedDependencies)?;
384
385 let edit = add_dependency(req, dev_dependencies, source.is_some(), raw)?;
386
387 if let Some(source) = source {
388 self.add_source(&req.name, source)?;
389 }
390
391 Ok(edit)
392 }
393
394 pub fn add_index(&mut self, index: &Index) -> Result<(), Error> {
396 let size = self.doc.len();
397 let existing = self
398 .doc
399 .entry("tool")
400 .or_insert(implicit())
401 .as_table_mut()
402 .ok_or(Error::MalformedSources)?
403 .entry("uv")
404 .or_insert(implicit())
405 .as_table_mut()
406 .ok_or(Error::MalformedSources)?
407 .entry("index")
408 .or_insert(Item::ArrayOfTables(ArrayOfTables::new()))
409 .as_array_of_tables_mut()
410 .ok_or(Error::MalformedSources)?;
411
412 let mut table = existing
414 .iter()
415 .find(|table| {
416 if let Some(index) = index.name.as_deref() {
418 if table
419 .get("name")
420 .and_then(|name| name.as_str())
421 .is_some_and(|name| name == index)
422 {
423 return true;
424 }
425 }
426
427 if index.default
429 && table
430 .get("default")
431 .is_some_and(|default| default.as_bool() == Some(true))
432 {
433 return true;
434 }
435
436 if table
438 .get("url")
439 .and_then(|item| item.as_str())
440 .and_then(|url| DisplaySafeUrl::parse(url).ok())
441 .is_some_and(|url| {
442 CanonicalUrl::new(&url) == CanonicalUrl::new(index.url.url())
443 })
444 {
445 return true;
446 }
447
448 false
449 })
450 .cloned()
451 .unwrap_or_default();
452
453 if let Some(index) = index.name.as_deref() {
455 if table
456 .get("name")
457 .and_then(|name| name.as_str())
458 .is_none_or(|name| name != index)
459 {
460 let mut formatted = Formatted::new(index.to_string());
461 if let Some(value) = table.get("name").and_then(Item::as_value) {
462 if let Some(prefix) = value.decor().prefix() {
463 formatted.decor_mut().set_prefix(prefix.clone());
464 }
465 if let Some(suffix) = value.decor().suffix() {
466 formatted.decor_mut().set_suffix(suffix.clone());
467 }
468 }
469 table.insert("name", Value::String(formatted).into());
470 }
471 }
472
473 if table
475 .get("url")
476 .and_then(|item| item.as_str())
477 .is_none_or(|url| url != index.url.without_credentials().as_str())
478 {
479 let mut formatted = Formatted::new(index.url.without_credentials().to_string());
480 if let Some(value) = table.get("url").and_then(Item::as_value) {
481 if let Some(prefix) = value.decor().prefix() {
482 formatted.decor_mut().set_prefix(prefix.clone());
483 }
484 if let Some(suffix) = value.decor().suffix() {
485 formatted.decor_mut().set_suffix(suffix.clone());
486 }
487 }
488 table.insert("url", Value::String(formatted).into());
489 }
490
491 if index.default {
493 if !table
494 .get("default")
495 .and_then(Item::as_bool)
496 .is_some_and(|default| default)
497 {
498 let mut formatted = Formatted::new(true);
499 if let Some(value) = table.get("default").and_then(Item::as_value) {
500 if let Some(prefix) = value.decor().prefix() {
501 formatted.decor_mut().set_prefix(prefix.clone());
502 }
503 if let Some(suffix) = value.decor().suffix() {
504 formatted.decor_mut().set_suffix(suffix.clone());
505 }
506 }
507 table.insert("default", Value::Boolean(formatted).into());
508 }
509 }
510
511 existing.retain(|table| {
513 if let Some(index) = index.name.as_deref() {
515 if table
516 .get("name")
517 .and_then(|name| name.as_str())
518 .is_some_and(|name| name == index)
519 {
520 return false;
521 }
522 }
523
524 if index.default
526 && table
527 .get("default")
528 .is_some_and(|default| default.as_bool() == Some(true))
529 {
530 return false;
531 }
532
533 if table
535 .get("url")
536 .and_then(|item| item.as_str())
537 .and_then(|url| DisplaySafeUrl::parse(url).ok())
538 .is_some_and(|url| CanonicalUrl::new(&url) == CanonicalUrl::new(index.url.url()))
539 {
540 return false;
541 }
542
543 true
544 });
545
546 if let Some(min) = existing.iter().filter_map(Table::position).min() {
548 table.set_position(min);
549
550 for table in existing.iter_mut() {
552 if let Some(position) = table.position() {
553 table.set_position(position + 1);
554 }
555 }
556 } else {
557 let position = isize::try_from(size).expect("TOML table size fits in `isize`");
558 table.set_position(position);
559 }
560
561 existing.push(table);
563
564 Ok(())
565 }
566
567 pub fn add_optional_dependency(
571 &mut self,
572 group: &ExtraName,
573 req: &Requirement,
574 source: Option<&Source>,
575 raw: bool,
576 ) -> Result<ArrayEdit, Error> {
577 let optional_dependencies = self
579 .project()?
580 .entry("optional-dependencies")
581 .or_insert(Item::Table(Table::new()))
582 .as_table_like_mut()
583 .ok_or(Error::MalformedDependencies)?;
584
585 let existing_group = optional_dependencies.iter_mut().find_map(|(key, value)| {
587 if ExtraName::from_str(key.get()).is_ok_and(|g| g == *group) {
588 Some(value)
589 } else {
590 None
591 }
592 });
593
594 let group = match existing_group {
596 Some(value) => value,
597 None => optional_dependencies
598 .entry(group.as_ref())
599 .or_insert(Item::Value(Value::Array(Array::new()))),
600 }
601 .as_array_mut()
602 .ok_or(Error::MalformedDependencies)?;
603
604 let added = add_dependency(req, group, source.is_some(), raw)?;
605
606 if let Some(optional_dependencies) = self
611 .project()?
612 .get_mut("optional-dependencies")
613 .and_then(Item::as_inline_table_mut)
614 {
615 optional_dependencies.fmt();
616 }
617
618 if let Some(source) = source {
619 self.add_source(&req.name, source)?;
620 }
621
622 Ok(added)
623 }
624
625 pub fn ensure_optional_dependency(&mut self, extra: &ExtraName) -> Result<(), Error> {
627 let optional_dependencies = self
629 .project()?
630 .entry("optional-dependencies")
631 .or_insert(Item::Table(Table::new()))
632 .as_table_like_mut()
633 .ok_or(Error::MalformedDependencies)?;
634
635 let extra_exists = optional_dependencies
637 .iter()
638 .any(|(key, _value)| ExtraName::from_str(key).is_ok_and(|e| e == *extra));
639
640 if !extra_exists {
642 optional_dependencies.insert(extra.as_ref(), Item::Value(Value::Array(Array::new())));
643 }
644
645 if let Some(optional_dependencies) = self
650 .project()?
651 .get_mut("optional-dependencies")
652 .and_then(Item::as_inline_table_mut)
653 {
654 optional_dependencies.fmt();
655 }
656
657 Ok(())
658 }
659
660 pub fn add_dependency_group_requirement(
664 &mut self,
665 group: &GroupName,
666 req: &Requirement,
667 source: Option<&Source>,
668 raw: bool,
669 ) -> Result<ArrayEdit, Error> {
670 let dependency_groups = self
672 .doc
673 .entry("dependency-groups")
674 .or_insert(Item::Table(Table::new()))
675 .as_table_like_mut()
676 .ok_or(Error::MalformedDependencies)?;
677
678 let was_sorted = dependency_groups
679 .get_values()
680 .iter()
681 .filter_map(|(dotted_ks, _)| dotted_ks.first())
682 .map(|k| k.get())
683 .is_sorted();
684
685 let existing_group = dependency_groups.iter_mut().find_map(|(key, value)| {
687 if GroupName::from_str(key.get()).is_ok_and(|g| g == *group) {
688 Some(value)
689 } else {
690 None
691 }
692 });
693
694 let group = match existing_group {
696 Some(value) => value,
697 None => dependency_groups
698 .entry(group.as_ref())
699 .or_insert(Item::Value(Value::Array(Array::new()))),
700 }
701 .as_array_mut()
702 .ok_or(Error::MalformedDependencies)?;
703
704 let added = add_dependency(req, group, source.is_some(), raw)?;
705
706 if was_sorted {
709 dependency_groups.sort_values();
710 }
711
712 if let Some(dependency_groups) = self
717 .doc
718 .get_mut("dependency-groups")
719 .and_then(Item::as_inline_table_mut)
720 {
721 dependency_groups.fmt();
722 }
723
724 if let Some(source) = source {
725 self.add_source(&req.name, source)?;
726 }
727
728 Ok(added)
729 }
730
731 pub fn ensure_dependency_group(&mut self, group: &GroupName) -> Result<(), Error> {
733 let dependency_groups = self
735 .doc
736 .entry("dependency-groups")
737 .or_insert(Item::Table(Table::new()))
738 .as_table_like_mut()
739 .ok_or(Error::MalformedDependencies)?;
740
741 let was_sorted = dependency_groups
742 .get_values()
743 .iter()
744 .filter_map(|(dotted_ks, _)| dotted_ks.first())
745 .map(|k| k.get())
746 .is_sorted();
747
748 let group_exists = dependency_groups
750 .iter()
751 .any(|(key, _value)| GroupName::from_str(key).is_ok_and(|g| g == *group));
752
753 if !group_exists {
755 dependency_groups.insert(group.as_ref(), Item::Value(Value::Array(Array::new())));
756
757 if was_sorted {
760 dependency_groups.sort_values();
761 }
762 }
763
764 if let Some(dependency_groups) = self
769 .doc
770 .get_mut("dependency-groups")
771 .and_then(Item::as_inline_table_mut)
772 {
773 dependency_groups.fmt();
774 }
775
776 Ok(())
777 }
778
779 pub fn set_dependency_bound(
781 &mut self,
782 dependency_type: &DependencyType,
783 index: usize,
784 version: Version,
785 bound_kind: AddBoundsKind,
786 ) -> Result<(), Error> {
787 let group = match dependency_type {
788 DependencyType::Production => self.dependencies_array()?,
789 DependencyType::Dev => self.dev_dependencies_array()?,
790 DependencyType::Optional(extra) => self.optional_dependencies_array(extra)?,
791 DependencyType::Group(group) => self.dependency_groups_array(group)?,
792 };
793
794 let Some(req) = group.get(index) else {
795 return Err(Error::MissingDependency(index));
796 };
797
798 let mut req = req
799 .as_str()
800 .and_then(try_parse_requirement)
801 .ok_or(Error::MalformedDependencies)?;
802 req.version_or_url = Some(VersionOrUrl::VersionSpecifier(
803 bound_kind.specifiers(version),
804 ));
805 group.replace(index, req.to_string());
806
807 Ok(())
808 }
809
810 fn dependencies_array(&mut self) -> Result<&mut Array, Error> {
812 let dependencies = self
814 .project()?
815 .entry("dependencies")
816 .or_insert(Item::Value(Value::Array(Array::new())))
817 .as_array_mut()
818 .ok_or(Error::MalformedDependencies)?;
819
820 Ok(dependencies)
821 }
822
823 fn dev_dependencies_array(&mut self) -> Result<&mut Array, Error> {
825 let dev_dependencies = self
827 .doc
828 .entry("tool")
829 .or_insert(implicit())
830 .as_table_mut()
831 .ok_or(Error::MalformedSources)?
832 .entry("uv")
833 .or_insert(Item::Table(Table::new()))
834 .as_table_mut()
835 .ok_or(Error::MalformedSources)?
836 .entry("dev-dependencies")
837 .or_insert(Item::Value(Value::Array(Array::new())))
838 .as_array_mut()
839 .ok_or(Error::MalformedDependencies)?;
840
841 Ok(dev_dependencies)
842 }
843
844 fn optional_dependencies_array(&mut self, group: &ExtraName) -> Result<&mut Array, Error> {
846 let optional_dependencies = self
848 .project()?
849 .entry("optional-dependencies")
850 .or_insert(Item::Table(Table::new()))
851 .as_table_like_mut()
852 .ok_or(Error::MalformedDependencies)?;
853
854 let existing_key = optional_dependencies.iter().find_map(|(key, _value)| {
856 if ExtraName::from_str(key).is_ok_and(|g| g == *group) {
857 Some(key.to_string())
858 } else {
859 None
860 }
861 });
862
863 let group = optional_dependencies
865 .entry(existing_key.as_deref().unwrap_or(group.as_ref()))
866 .or_insert(Item::Value(Value::Array(Array::new())))
867 .as_array_mut()
868 .ok_or(Error::MalformedDependencies)?;
869
870 Ok(group)
871 }
872
873 fn dependency_groups_array(&mut self, group: &GroupName) -> Result<&mut Array, Error> {
875 let dependency_groups = self
877 .doc
878 .entry("dependency-groups")
879 .or_insert(Item::Table(Table::new()))
880 .as_table_like_mut()
881 .ok_or(Error::MalformedDependencies)?;
882
883 let existing_key = dependency_groups.iter().find_map(|(key, _value)| {
885 if GroupName::from_str(key).is_ok_and(|g| g == *group) {
886 Some(key.to_string())
887 } else {
888 None
889 }
890 });
891
892 let group = dependency_groups
894 .entry(existing_key.as_deref().unwrap_or(group.as_ref()))
895 .or_insert(Item::Value(Value::Array(Array::new())))
896 .as_array_mut()
897 .ok_or(Error::MalformedDependencies)?;
898
899 Ok(group)
900 }
901
902 fn add_source(&mut self, name: &PackageName, source: &Source) -> Result<(), Error> {
904 let sources = self
906 .doc
907 .entry("tool")
908 .or_insert(implicit())
909 .as_table_mut()
910 .ok_or(Error::MalformedSources)?
911 .entry("uv")
912 .or_insert(implicit())
913 .as_table_mut()
914 .ok_or(Error::MalformedSources)?
915 .entry("sources")
916 .or_insert(Item::Table(Table::new()))
917 .as_table_mut()
918 .ok_or(Error::MalformedSources)?;
919
920 if let Some(key) = find_source(name, sources) {
921 sources.remove(&key);
922 }
923 add_source(name, source, sources)?;
924
925 Ok(())
926 }
927
928 pub fn remove_dependency(&mut self, name: &PackageName) -> Result<Vec<Requirement>, Error> {
930 let Some(dependencies) = self
932 .project_mut()?
933 .and_then(|project| project.get_mut("dependencies"))
934 .map(|dependencies| {
935 dependencies
936 .as_array_mut()
937 .ok_or(Error::MalformedDependencies)
938 })
939 .transpose()?
940 else {
941 return Ok(Vec::new());
942 };
943
944 let requirements = remove_dependency(name, dependencies);
945 self.remove_source(name)?;
946
947 Ok(requirements)
948 }
949
950 pub fn remove_dev_dependency(&mut self, name: &PackageName) -> Result<Vec<Requirement>, Error> {
952 let Some(dev_dependencies) = self
954 .doc
955 .get_mut("tool")
956 .map(|tool| tool.as_table_mut().ok_or(Error::MalformedDependencies))
957 .transpose()?
958 .and_then(|tool| tool.get_mut("uv"))
959 .map(|tool_uv| tool_uv.as_table_mut().ok_or(Error::MalformedDependencies))
960 .transpose()?
961 .and_then(|tool_uv| tool_uv.get_mut("dev-dependencies"))
962 .map(|dependencies| {
963 dependencies
964 .as_array_mut()
965 .ok_or(Error::MalformedDependencies)
966 })
967 .transpose()?
968 else {
969 return Ok(Vec::new());
970 };
971
972 let requirements = remove_dependency(name, dev_dependencies);
973 self.remove_source(name)?;
974
975 Ok(requirements)
976 }
977
978 pub fn remove_optional_dependency(
980 &mut self,
981 name: &PackageName,
982 group: &ExtraName,
983 ) -> Result<Vec<Requirement>, Error> {
984 let Some(optional_dependencies) = self
986 .project_mut()?
987 .and_then(|project| project.get_mut("optional-dependencies"))
988 .map(|extras| {
989 extras
990 .as_table_like_mut()
991 .ok_or(Error::MalformedDependencies)
992 })
993 .transpose()?
994 .and_then(|extras| {
995 extras.iter_mut().find_map(|(key, value)| {
996 if ExtraName::from_str(key.get()).is_ok_and(|g| g == *group) {
997 Some(value)
998 } else {
999 None
1000 }
1001 })
1002 })
1003 .map(|dependencies| {
1004 dependencies
1005 .as_array_mut()
1006 .ok_or(Error::MalformedDependencies)
1007 })
1008 .transpose()?
1009 else {
1010 return Ok(Vec::new());
1011 };
1012
1013 let requirements = remove_dependency(name, optional_dependencies);
1014 self.remove_source(name)?;
1015
1016 Ok(requirements)
1017 }
1018
1019 pub fn remove_dependency_group_requirement(
1021 &mut self,
1022 name: &PackageName,
1023 group: &GroupName,
1024 ) -> Result<Vec<Requirement>, Error> {
1025 let Some(group_dependencies) = self
1027 .doc
1028 .get_mut("dependency-groups")
1029 .map(|groups| {
1030 groups
1031 .as_table_like_mut()
1032 .ok_or(Error::MalformedDependencies)
1033 })
1034 .transpose()?
1035 .and_then(|groups| {
1036 groups.iter_mut().find_map(|(key, value)| {
1037 if GroupName::from_str(key.get()).is_ok_and(|g| g == *group) {
1038 Some(value)
1039 } else {
1040 None
1041 }
1042 })
1043 })
1044 .map(|dependencies| {
1045 dependencies
1046 .as_array_mut()
1047 .ok_or(Error::MalformedDependencies)
1048 })
1049 .transpose()?
1050 else {
1051 return Ok(Vec::new());
1052 };
1053
1054 let requirements = remove_dependency(name, group_dependencies);
1055 self.remove_source(name)?;
1056
1057 Ok(requirements)
1058 }
1059
1060 fn remove_source(&mut self, name: &PackageName) -> Result<(), Error> {
1062 if !self.find_dependency(name, None).is_empty() {
1064 return Ok(());
1065 }
1066
1067 if let Some(sources) = self
1068 .doc
1069 .get_mut("tool")
1070 .map(|tool| tool.as_table_mut().ok_or(Error::MalformedSources))
1071 .transpose()?
1072 .and_then(|tool| tool.get_mut("uv"))
1073 .map(|tool_uv| tool_uv.as_table_mut().ok_or(Error::MalformedSources))
1074 .transpose()?
1075 .and_then(|tool_uv| tool_uv.get_mut("sources"))
1076 .map(|sources| sources.as_table_mut().ok_or(Error::MalformedSources))
1077 .transpose()?
1078 {
1079 if let Some(key) = find_source(name, sources) {
1080 sources.remove(&key);
1081
1082 if sources.is_empty() {
1084 self.doc
1085 .entry("tool")
1086 .or_insert(implicit())
1087 .as_table_mut()
1088 .ok_or(Error::MalformedSources)?
1089 .entry("uv")
1090 .or_insert(implicit())
1091 .as_table_mut()
1092 .ok_or(Error::MalformedSources)?
1093 .remove("sources");
1094 }
1095 }
1096 }
1097
1098 Ok(())
1099 }
1100
1101 pub fn has_dev_dependencies(&self) -> bool {
1103 self.doc
1104 .get("tool")
1105 .and_then(Item::as_table)
1106 .and_then(|tool| tool.get("uv"))
1107 .and_then(Item::as_table)
1108 .and_then(|uv| uv.get("dev-dependencies"))
1109 .is_some()
1110 }
1111
1112 pub fn has_dependency_group(&self, group: &GroupName) -> bool {
1114 self.doc
1115 .get("dependency-groups")
1116 .and_then(Item::as_table)
1117 .and_then(|groups| groups.get(group.as_ref()))
1118 .is_some()
1119 }
1120
1121 pub fn find_dependency(
1127 &self,
1128 name: &PackageName,
1129 marker: Option<&MarkerTree>,
1130 ) -> Vec<DependencyType> {
1131 let mut types = Vec::new();
1132
1133 if let Some(project) = self.doc.get("project").and_then(Item::as_table) {
1134 if let Some(dependencies) = project.get("dependencies").and_then(Item::as_array) {
1136 if !find_dependencies(name, marker, dependencies).is_empty() {
1137 types.push(DependencyType::Production);
1138 }
1139 }
1140
1141 if let Some(extras) = project
1143 .get("optional-dependencies")
1144 .and_then(Item::as_table)
1145 {
1146 for (extra, dependencies) in extras {
1147 let Some(dependencies) = dependencies.as_array() else {
1148 continue;
1149 };
1150 let Ok(extra) = ExtraName::from_str(extra) else {
1151 continue;
1152 };
1153
1154 if !find_dependencies(name, marker, dependencies).is_empty() {
1155 types.push(DependencyType::Optional(extra));
1156 }
1157 }
1158 }
1159 }
1160
1161 if let Some(groups) = self.doc.get("dependency-groups").and_then(Item::as_table) {
1163 for (group, dependencies) in groups {
1164 let Some(dependencies) = dependencies.as_array() else {
1165 continue;
1166 };
1167 let Ok(group) = GroupName::from_str(group) else {
1168 continue;
1169 };
1170
1171 if !find_dependencies(name, marker, dependencies).is_empty() {
1172 types.push(DependencyType::Group(group));
1173 }
1174 }
1175 }
1176
1177 if let Some(dev_dependencies) = self
1179 .doc
1180 .get("tool")
1181 .and_then(Item::as_table)
1182 .and_then(|tool| tool.get("uv"))
1183 .and_then(Item::as_table)
1184 .and_then(|uv| uv.get("dev-dependencies"))
1185 .and_then(Item::as_array)
1186 {
1187 if !find_dependencies(name, marker, dev_dependencies).is_empty() {
1188 types.push(DependencyType::Dev);
1189 }
1190 }
1191
1192 types
1193 }
1194
1195 pub fn version(&mut self) -> Result<Version, Error> {
1196 let version = self
1197 .doc
1198 .get("project")
1199 .and_then(Item::as_table)
1200 .and_then(|project| project.get("version"))
1201 .and_then(Item::as_str)
1202 .ok_or(Error::MalformedWorkspace)?;
1203
1204 Ok(Version::from_str(version)?)
1205 }
1206
1207 pub fn has_dynamic_version(&mut self) -> bool {
1208 let Some(dynamic) = self
1209 .doc
1210 .get("project")
1211 .and_then(Item::as_table)
1212 .and_then(|project| project.get("dynamic"))
1213 .and_then(Item::as_array)
1214 else {
1215 return false;
1216 };
1217
1218 dynamic.iter().any(|val| val.as_str() == Some("version"))
1219 }
1220
1221 pub fn set_version(&mut self, version: &Version) -> Result<(), Error> {
1222 let project = self
1223 .doc
1224 .get_mut("project")
1225 .and_then(Item::as_table_mut)
1226 .ok_or(Error::MalformedWorkspace)?;
1227
1228 if let Some(existing) = project.get_mut("version") {
1229 if let Some(value) = existing.as_value_mut() {
1230 let mut formatted = Value::from(version.to_string());
1231 *formatted.decor_mut() = value.decor().clone();
1232 *value = formatted;
1233 } else {
1234 *existing = Item::Value(Value::from(version.to_string()));
1235 }
1236 } else {
1237 project.insert("version", Item::Value(Value::from(version.to_string())));
1238 }
1239
1240 Ok(())
1241 }
1242}
1243
1244fn implicit() -> Item {
1246 let mut table = Table::new();
1247 table.set_implicit(true);
1248 Item::Table(table)
1249}
1250
1251pub fn add_dependency(
1255 req: &Requirement,
1256 deps: &mut Array,
1257 has_source: bool,
1258 raw: bool,
1259) -> Result<ArrayEdit, Error> {
1260 let mut to_replace = find_dependencies(&req.name, Some(&req.marker), deps);
1261
1262 match to_replace.as_slice() {
1263 [] => {
1264 #[derive(Debug, Copy, Clone)]
1265 enum Sort {
1266 CaseInsensitive,
1268 CaseInsensitiveNaive,
1270 CaseSensitive,
1272 CaseSensitiveNaive,
1274 Unsorted,
1276 }
1277
1278 fn is_sorted<T, I>(items: I) -> bool
1279 where
1280 I: IntoIterator<Item = T>,
1281 T: PartialOrd + Copy,
1282 {
1283 items.into_iter().tuple_windows().all(|(a, b)| a <= b)
1284 }
1285
1286 let reqs: Vec<_> = deps.iter().filter_map(Value::as_str).collect();
1289 let reqs_lowercase: Vec<_> = reqs.iter().copied().map(str::to_lowercase).collect();
1290
1291 let sort = if is_sorted(
1301 reqs_lowercase
1302 .iter()
1303 .map(String::as_str)
1304 .map(split_specifiers),
1305 ) {
1306 Sort::CaseInsensitive
1307 } else if is_sorted(reqs.iter().copied().map(split_specifiers)) {
1308 Sort::CaseSensitive
1309 } else if is_sorted(reqs_lowercase.iter().map(String::as_str)) {
1310 Sort::CaseInsensitiveNaive
1311 } else if is_sorted(reqs) {
1312 Sort::CaseSensitiveNaive
1313 } else {
1314 Sort::Unsorted
1315 };
1316
1317 let req_string = if raw {
1318 req.displayable_with_credentials().to_string()
1319 } else {
1320 req.to_string()
1321 };
1322 let index = match sort {
1323 Sort::CaseInsensitive => deps.iter().position(|dep| {
1324 dep.as_str().is_some_and(|dep| {
1325 split_specifiers(&dep.to_lowercase())
1326 > split_specifiers(&req_string.to_lowercase())
1327 })
1328 }),
1329 Sort::CaseInsensitiveNaive => deps.iter().position(|dep| {
1330 dep.as_str()
1331 .is_some_and(|dep| dep.to_lowercase() > req_string.to_lowercase())
1332 }),
1333 Sort::CaseSensitive => deps.iter().position(|dep| {
1334 dep.as_str()
1335 .is_some_and(|dep| split_specifiers(dep) > split_specifiers(&req_string))
1336 }),
1337 Sort::CaseSensitiveNaive => deps
1338 .iter()
1339 .position(|dep| dep.as_str().is_some_and(|dep| *dep > *req_string)),
1340 Sort::Unsorted => None,
1341 };
1342 let index = index.unwrap_or_else(|| {
1343 deps.iter()
1347 .enumerate()
1348 .filter_map(|(i, dep)| if dep.is_str() { Some(i + 1) } else { None })
1349 .last()
1350 .unwrap_or(deps.len())
1351 });
1352
1353 let mut value = Value::from(req_string.as_str());
1354
1355 let decor = value.decor_mut();
1356
1357 match index {
1359 val if val == deps.len() => {
1360 decor.set_prefix(deps.trailing().clone());
1378 deps.set_trailing("");
1379 }
1380 0 => {
1381 }
1383 val => {
1384 let targeted_decor = deps.get_mut(val).unwrap().decor_mut();
1403 decor.set_prefix(targeted_decor.prefix().unwrap().clone());
1404 targeted_decor.set_prefix(""); }
1406 }
1407
1408 deps.insert_formatted(index, value);
1409
1410 if deps.len() > 1 && index == 0 {
1416 let prefix = deps
1417 .clone()
1418 .get(index + 1)
1419 .unwrap()
1420 .decor()
1421 .prefix()
1422 .unwrap()
1423 .clone();
1424
1425 if let Some(prefix) = prefix.as_str() {
1463 if let Some((first_line, rest)) = prefix.split_once(['\r', '\n']) {
1470 let newline = {
1472 let mut chars = prefix[first_line.len()..].chars();
1473 match (chars.next(), chars.next()) {
1474 (Some('\r'), Some('\n')) => "\r\n",
1475 (Some('\r'), _) => "\r",
1476 (Some('\n'), _) => "\n",
1477 _ => "\n",
1478 }
1479 };
1480 let last_line = rest.lines().last().unwrap_or_default();
1481 let prefix = format!("{first_line}{newline}{last_line}");
1482 deps.get_mut(index).unwrap().decor_mut().set_prefix(prefix);
1483
1484 let prefix = format!("{newline}{rest}");
1485 deps.get_mut(index + 1)
1486 .unwrap()
1487 .decor_mut()
1488 .set_prefix(prefix);
1489 } else {
1490 deps.get_mut(index).unwrap().decor_mut().set_prefix(prefix);
1491 }
1492 } else {
1493 deps.get_mut(index).unwrap().decor_mut().set_prefix(prefix);
1494 }
1495 }
1496
1497 reformat_array_multiline(deps);
1498
1499 Ok(ArrayEdit::Add(index))
1500 }
1501 [_] => {
1502 let (i, mut old_req) = to_replace.remove(0);
1503 update_requirement(&mut old_req, req, has_source);
1504 deps.replace(i, old_req.to_string());
1505 reformat_array_multiline(deps);
1506 Ok(ArrayEdit::Update(i))
1507 }
1508 _ => Err(Error::Ambiguous {
1510 package_name: req.name.clone(),
1511 requirements: to_replace
1512 .into_iter()
1513 .map(|(_, requirement)| requirement)
1514 .collect(),
1515 }),
1516 }
1517}
1518
1519fn update_requirement(old: &mut Requirement, new: &Requirement, has_source: bool) {
1521 let mut extras = old.extras.to_vec();
1523 extras.extend(new.extras.iter().cloned());
1524 extras.sort_unstable();
1525 extras.dedup();
1526 old.extras = extras.into_boxed_slice();
1527
1528 if has_source {
1530 old.clear_url();
1531 }
1532
1533 match &new.version_or_url {
1535 None => {}
1536 Some(VersionOrUrl::VersionSpecifier(specifier)) if specifier.is_empty() => {}
1537 Some(version_or_url) => old.version_or_url = Some(version_or_url.clone()),
1538 }
1539
1540 if new.marker.contents().is_some() {
1542 old.marker = new.marker;
1543 }
1544}
1545
1546fn remove_dependency(name: &PackageName, deps: &mut Array) -> Vec<Requirement> {
1548 let removed = find_dependencies(name, None, deps)
1550 .into_iter()
1551 .rev() .filter_map(|(i, _)| {
1553 deps.remove(i)
1554 .as_str()
1555 .and_then(|req| Requirement::from_str(req).ok())
1556 })
1557 .collect::<Vec<_>>();
1558
1559 if !removed.is_empty() {
1560 reformat_array_multiline(deps);
1561 }
1562
1563 removed
1564}
1565
1566fn find_dependencies(
1569 name: &PackageName,
1570 marker: Option<&MarkerTree>,
1571 deps: &Array,
1572) -> Vec<(usize, Requirement)> {
1573 let mut to_replace = Vec::new();
1574 for (i, dep) in deps.iter().enumerate() {
1575 if let Some(req) = dep.as_str().and_then(try_parse_requirement) {
1576 if marker.is_none_or(|m| *m == req.marker) && *name == req.name {
1577 to_replace.push((i, req));
1578 }
1579 }
1580 }
1581 to_replace
1582}
1583
1584fn find_source(name: &PackageName, sources: &Table) -> Option<String> {
1586 for (key, _) in sources {
1587 if PackageName::from_str(key).is_ok_and(|ref key| key == name) {
1588 return Some(key.to_string());
1589 }
1590 }
1591 None
1592}
1593
1594fn add_source(req: &PackageName, source: &Source, sources: &mut Table) -> Result<(), Error> {
1596 let mut doc = toml::to_string(&source)
1598 .map_err(Box::new)?
1599 .parse::<DocumentMut>()
1600 .unwrap();
1601 let table = mem::take(doc.as_table_mut()).into_inline_table();
1602
1603 sources.insert(req.as_ref(), Item::Value(Value::InlineTable(table)));
1604
1605 Ok(())
1606}
1607
1608impl fmt::Display for PyProjectTomlMut {
1609 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1610 self.doc.fmt(f)
1611 }
1612}
1613
1614fn try_parse_requirement(req: &str) -> Option<Requirement> {
1615 Requirement::from_str(req).ok()
1616}
1617
1618fn reformat_array_multiline(deps: &mut Array) {
1621 fn find_comments(s: Option<&RawString>) -> Box<dyn Iterator<Item = Comment> + '_> {
1622 let iter = s
1623 .and_then(|x| x.as_str())
1624 .unwrap_or("")
1625 .lines()
1626 .scan(
1627 (false, false),
1628 |(prev_line_was_empty, prev_line_was_comment), line| {
1629 let trimmed_line = line.trim();
1630
1631 if let Some((before, comment)) = line.split_once('#') {
1632 let comment_text = format!("#{}", comment.trim_end());
1633
1634 let comment_kind = if (*prev_line_was_empty) || (*prev_line_was_comment) {
1635 CommentType::OwnLine
1636 } else {
1637 CommentType::EndOfLine {
1638 leading_whitespace: before
1639 .chars()
1640 .rev()
1641 .take_while(|c| c.is_whitespace())
1642 .collect::<String>()
1643 .chars()
1644 .rev()
1645 .collect(),
1646 }
1647 };
1648
1649 *prev_line_was_empty = trimmed_line.is_empty();
1650 *prev_line_was_comment = true;
1651
1652 Some(Some(Comment {
1653 text: comment_text,
1654 kind: comment_kind,
1655 }))
1656 } else {
1657 *prev_line_was_empty = trimmed_line.is_empty();
1658 *prev_line_was_comment = false;
1659 Some(None)
1660 }
1661 },
1662 )
1663 .flatten();
1664
1665 Box::new(iter)
1666 }
1667
1668 let mut indentation_prefix = None;
1669
1670 if let Some(first_item) = deps.iter().next() {
1672 let decor_prefix = first_item
1673 .decor()
1674 .prefix()
1675 .and_then(|s| s.as_str())
1676 .and_then(|s| s.lines().last())
1677 .unwrap_or_default();
1678
1679 let decor_prefix = decor_prefix
1680 .split_once('#')
1681 .map(|(s, _)| s)
1682 .unwrap_or(decor_prefix);
1683
1684 indentation_prefix = (!decor_prefix.is_empty()).then_some(decor_prefix.to_string());
1685 }
1686
1687 let indentation_prefix_str = format!("\n{}", indentation_prefix.as_deref().unwrap_or(" "));
1688
1689 for item in deps.iter_mut() {
1690 let decor = item.decor_mut();
1691 let mut prefix = String::new();
1692
1693 for comment in find_comments(decor.prefix()).chain(find_comments(decor.suffix())) {
1694 match &comment.kind {
1695 CommentType::OwnLine => {
1696 prefix.push_str(&indentation_prefix_str);
1697 }
1698 CommentType::EndOfLine { leading_whitespace } => {
1699 prefix.push_str(leading_whitespace);
1700 }
1701 }
1702 prefix.push_str(&comment.text);
1703 }
1704 prefix.push_str(&indentation_prefix_str);
1705 decor.set_prefix(prefix);
1706 decor.set_suffix("");
1707 }
1708
1709 deps.set_trailing(&{
1710 let mut comments = find_comments(Some(deps.trailing())).peekable();
1711 let mut rv = String::new();
1712 if comments.peek().is_some() {
1713 for comment in comments {
1714 match &comment.kind {
1715 CommentType::OwnLine => {
1716 let indentation_prefix_str =
1717 format!("\n{}", indentation_prefix.as_deref().unwrap_or(" "));
1718 rv.push_str(&indentation_prefix_str);
1719 }
1720 CommentType::EndOfLine { leading_whitespace } => {
1721 rv.push_str(leading_whitespace);
1722 }
1723 }
1724 rv.push_str(&comment.text);
1725 }
1726 }
1727 if !rv.is_empty() || !deps.is_empty() {
1728 rv.push('\n');
1729 }
1730 rv
1731 });
1732 deps.set_trailing_comma(true);
1733}
1734
1735fn split_specifiers(req: &str) -> (&str, &str) {
1742 let (name, specifiers) = req
1743 .find(['>', '<', '=', '~', '!', '@'])
1744 .map_or((req, ""), |pos| {
1745 let (name, specifiers) = req.split_at(pos);
1746 (name, specifiers)
1747 });
1748 (name.trim(), specifiers.trim())
1749}
1750
1751#[cfg(test)]
1752mod test {
1753 use super::{AddBoundsKind, reformat_array_multiline, split_specifiers};
1754 use std::str::FromStr;
1755 use toml_edit::DocumentMut;
1756 use uv_pep440::Version;
1757
1758 #[test]
1759 fn split() {
1760 assert_eq!(split_specifiers("flask>=1.0"), ("flask", ">=1.0"));
1761 assert_eq!(split_specifiers("Flask>=1.0"), ("Flask", ">=1.0"));
1762 assert_eq!(
1763 split_specifiers("flask[dotenv]>=1.0"),
1764 ("flask[dotenv]", ">=1.0")
1765 );
1766 assert_eq!(split_specifiers("flask[dotenv]"), ("flask[dotenv]", ""));
1767 assert_eq!(
1768 split_specifiers(
1769 "flask @ https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl"
1770 ),
1771 (
1772 "flask",
1773 "@ https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl"
1774 )
1775 );
1776 }
1777
1778 #[test]
1779 fn reformat_preserves_inline_comment_spacing() {
1780 let mut doc: DocumentMut = r#"
1781[project]
1782dependencies = [
1783 "attrs>=25.4.0", # comment
1784]
1785"#
1786 .parse()
1787 .unwrap();
1788
1789 reformat_array_multiline(
1790 doc["project"]["dependencies"]
1791 .as_array_mut()
1792 .expect("dependencies array"),
1793 );
1794
1795 let serialized = doc.to_string();
1796
1797 assert!(
1798 serialized.contains("\"attrs>=25.4.0\", # comment"),
1799 "inline comment spacing should be preserved:\n{serialized}"
1800 );
1801 }
1802
1803 #[test]
1804 fn reformat_preserves_inline_comment_without_padding() {
1805 let mut doc: DocumentMut = r#"
1806[project]
1807dependencies = [
1808 "attrs>=25.4.0",#comment
1809]
1810"#
1811 .parse()
1812 .unwrap();
1813
1814 reformat_array_multiline(
1815 doc["project"]["dependencies"]
1816 .as_array_mut()
1817 .expect("dependencies array"),
1818 );
1819
1820 let serialized = doc.to_string();
1821
1822 assert!(
1823 serialized.contains("\"attrs>=25.4.0\",#comment"),
1824 "inline comment spacing without padding should be preserved:\n{serialized}"
1825 );
1826 }
1827
1828 #[test]
1829 fn bound_kind_to_specifiers_exact() {
1830 let tests = [
1831 ("0", "==0"),
1832 ("0.0", "==0.0"),
1833 ("0.0.0", "==0.0.0"),
1834 ("0.1", "==0.1"),
1835 ("0.0.1", "==0.0.1"),
1836 ("0.0.0.1", "==0.0.0.1"),
1837 ("1.0.0", "==1.0.0"),
1838 ("1.2", "==1.2"),
1839 ("1.2.3", "==1.2.3"),
1840 ("1.2.3.4", "==1.2.3.4"),
1841 ("1.2.3.4a1.post1", "==1.2.3.4a1.post1"),
1842 ];
1843
1844 for (version, expected) in tests {
1845 let actual = AddBoundsKind::Exact
1846 .specifiers(Version::from_str(version).unwrap())
1847 .to_string();
1848 assert_eq!(actual, expected, "{version}");
1849 }
1850 }
1851
1852 #[test]
1853 fn bound_kind_to_specifiers_lower() {
1854 let tests = [
1855 ("0", ">=0"),
1856 ("0.0", ">=0.0"),
1857 ("0.0.0", ">=0.0.0"),
1858 ("0.1", ">=0.1"),
1859 ("0.0.1", ">=0.0.1"),
1860 ("0.0.0.1", ">=0.0.0.1"),
1861 ("1", ">=1"),
1862 ("1.0.0", ">=1.0.0"),
1863 ("1.2", ">=1.2"),
1864 ("1.2.3", ">=1.2.3"),
1865 ("1.2.3.4", ">=1.2.3.4"),
1866 ("1.2.3.4a1.post1", ">=1.2.3.4a1.post1"),
1867 ];
1868
1869 for (version, expected) in tests {
1870 let actual = AddBoundsKind::Lower
1871 .specifiers(Version::from_str(version).unwrap())
1872 .to_string();
1873 assert_eq!(actual, expected, "{version}");
1874 }
1875 }
1876
1877 #[test]
1878 fn bound_kind_to_specifiers_major() {
1879 let tests = [
1880 ("0", ">=0, <0.1"),
1881 ("0.0", ">=0.0, <0.1"),
1882 ("0.0.0", ">=0.0.0, <0.1.0"),
1883 ("0.0.0.0", ">=0.0.0.0, <0.1.0.0"),
1884 ("0.1", ">=0.1, <0.2"),
1885 ("0.0.1", ">=0.0.1, <0.0.2"),
1886 ("0.0.1.1", ">=0.0.1.1, <0.0.2.0"),
1887 ("0.0.0.1", ">=0.0.0.1, <0.0.0.2"),
1888 ("1", ">=1, <2"),
1889 ("1.0.0", ">=1.0.0, <2.0.0"),
1890 ("1.2", ">=1.2, <2.0"),
1891 ("1.2.3", ">=1.2.3, <2.0.0"),
1892 ("1.2.3.4", ">=1.2.3.4, <2.0.0.0"),
1893 ("1.2.3.4a1.post1", ">=1.2.3.4a1.post1, <2.0.0.0"),
1894 ];
1895
1896 for (version, expected) in tests {
1897 let actual = AddBoundsKind::Major
1898 .specifiers(Version::from_str(version).unwrap())
1899 .to_string();
1900 assert_eq!(actual, expected, "{version}");
1901 }
1902 }
1903
1904 #[test]
1905 fn bound_kind_to_specifiers_minor() {
1906 let tests = [
1907 ("0", ">=0, <0.0.1"),
1908 ("0.0", ">=0.0, <0.0.1"),
1909 ("0.0.0", ">=0.0.0, <0.0.1"),
1910 ("0.0.0.0", ">=0.0.0.0, <0.0.1.0"),
1911 ("0.1", ">=0.1, <0.1.1"),
1912 ("0.0.1", ">=0.0.1, <0.0.2"),
1913 ("0.0.1.1", ">=0.0.1.1, <0.0.2.0"),
1914 ("0.0.0.1", ">=0.0.0.1, <0.0.0.2"),
1915 ("1", ">=1, <1.1"),
1916 ("1.0.0", ">=1.0.0, <1.1.0"),
1917 ("1.2", ">=1.2, <1.3"),
1918 ("1.2.3", ">=1.2.3, <1.3.0"),
1919 ("1.2.3.4", ">=1.2.3.4, <1.3.0.0"),
1920 ("1.2.3.4a1.post1", ">=1.2.3.4a1.post1, <1.3.0.0"),
1921 ];
1922
1923 for (version, expected) in tests {
1924 let actual = AddBoundsKind::Minor
1925 .specifiers(Version::from_str(version).unwrap())
1926 .to_string();
1927 assert_eq!(actual, expected, "{version}");
1928 }
1929 }
1930}