1use std::collections::{HashMap, HashSet};
2
3use changeset_core::{PackageInfo, PrereleaseSpec};
4use changeset_project::{GraduationState, PrereleaseState, ProjectKind};
5use changeset_version::{is_prerelease, is_zero_version};
6
7use crate::types::PackageReleaseConfig;
8
9#[derive(Debug, Clone, Default)]
11pub struct ReleaseCliInput {
12 pub cli_prerelease: HashMap<String, PrereleaseSpec>,
14 pub global_prerelease: Option<PrereleaseSpec>,
16 pub cli_graduate: HashSet<String>,
18 pub graduate_all: bool,
20}
21
22#[derive(Debug, Clone)]
24pub enum ValidationError {
25 ConflictingPrereleaseTag {
27 package: String,
28 cli_tag: String,
29 toml_tag: String,
30 },
31 CannotGraduateFromPrerelease {
33 package: String,
34 current_version: String,
35 },
36 GraduateRequiresCratesInWorkspace,
38 PackageNotFound {
40 name: String,
41 available: Vec<String>,
42 },
43 CannotGraduateStableVersion { package: String, version: String },
45 InvalidPrereleaseTag {
47 package: String,
48 tag: String,
49 reason: String,
50 },
51}
52
53impl ValidationError {
54 #[must_use]
56 pub fn tip(&self) -> String {
57 match self {
58 Self::ConflictingPrereleaseTag {
59 package, toml_tag, ..
60 } => {
61 format!(
62 "Run `cargo changeset manage pre-release --remove {package}` to clear TOML, \
63 or use `--prerelease {package}:{toml_tag}` to match"
64 )
65 }
66 Self::CannotGraduateFromPrerelease { package, .. } => {
67 format!(
68 "First release {package} to stable with `cargo changeset release`, \
69 then graduate with `--graduate {package}`"
70 )
71 }
72 Self::GraduateRequiresCratesInWorkspace => {
73 "Specify crates: `--graduate crate-a --graduate crate-b`".to_string()
74 }
75 Self::PackageNotFound { name, available } => {
76 let available_str = available.join(", ");
77 format!("Package '{name}' not found. Available: {available_str}")
78 }
79 Self::CannotGraduateStableVersion { package, version } => {
80 format!("Package {package} is already at {version}; graduation is for 0.x only")
81 }
82 Self::InvalidPrereleaseTag { package, .. } => {
83 format!(
84 "Run `cargo changeset manage pre-release --remove {package}` and re-add with a valid tag"
85 )
86 }
87 }
88 }
89}
90
91impl std::fmt::Display for ValidationError {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 match self {
94 Self::ConflictingPrereleaseTag {
95 package,
96 cli_tag,
97 toml_tag,
98 } => {
99 write!(
100 f,
101 "conflicting prerelease tag for '{package}': CLI specifies '{cli_tag}', \
102 pre-release.toml specifies '{toml_tag}'"
103 )
104 }
105 Self::CannotGraduateFromPrerelease {
106 package,
107 current_version,
108 } => {
109 write!(
110 f,
111 "cannot graduate '{package}': currently in prerelease ({current_version})"
112 )
113 }
114 Self::GraduateRequiresCratesInWorkspace => {
115 write!(f, "--graduate requires crate names in workspace")
116 }
117 Self::PackageNotFound { name, .. } => {
118 write!(f, "package '{name}' not found in workspace")
119 }
120 Self::CannotGraduateStableVersion { package, version } => {
121 write!(
122 f,
123 "cannot graduate '{package}': already at stable version {version}"
124 )
125 }
126 Self::InvalidPrereleaseTag {
127 package,
128 tag,
129 reason,
130 } => {
131 write!(
132 f,
133 "invalid prerelease tag '{tag}' in pre-release.toml for package '{package}': \
134 {reason}"
135 )
136 }
137 }
138 }
139}
140
141#[derive(Debug)]
150#[allow(clippy::len_without_is_empty)]
151pub struct ValidationErrors {
152 first: ValidationError,
153 rest: Vec<ValidationError>,
154}
155
156impl ValidationErrors {
157 #[must_use]
163 pub fn from_vec(mut errors: Vec<ValidationError>) -> Self {
164 assert!(
165 !errors.is_empty(),
166 "ValidationErrors must contain at least one error"
167 );
168 let first = errors.remove(0);
169 Self {
170 first,
171 rest: errors,
172 }
173 }
174
175 #[must_use]
177 pub fn try_from_vec(mut errors: Vec<ValidationError>) -> Option<Self> {
178 if errors.is_empty() {
179 return None;
180 }
181 let first = errors.remove(0);
182 Some(Self {
183 first,
184 rest: errors,
185 })
186 }
187
188 #[must_use]
189 pub fn len(&self) -> usize {
190 1 + self.rest.len()
191 }
192
193 #[must_use]
194 pub fn into_vec(self) -> Vec<ValidationError> {
195 let mut errors = vec![self.first];
196 errors.extend(self.rest);
197 errors
198 }
199
200 pub fn iter(&self) -> impl Iterator<Item = &ValidationError> {
201 std::iter::once(&self.first).chain(self.rest.iter())
202 }
203}
204
205#[derive(Debug, Default)]
211pub(crate) struct ValidationErrorCollector {
212 errors: Vec<ValidationError>,
213}
214
215impl ValidationErrorCollector {
216 pub fn new() -> Self {
217 Self::default()
218 }
219
220 pub fn push(&mut self, error: ValidationError) {
221 self.errors.push(error);
222 }
223
224 pub fn into_errors(self) -> Option<ValidationErrors> {
226 ValidationErrors::try_from_vec(self.errors)
227 }
228}
229
230impl std::fmt::Display for ValidationErrors {
231 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232 writeln!(f, "validation failed with {} error(s):", self.len())?;
233 for (i, error) in self.iter().enumerate() {
234 writeln!(f, " {}. {error}", i + 1)?;
235 writeln!(f, " Tip: {}", error.tip())?;
236 }
237 Ok(())
238 }
239}
240
241impl std::error::Error for ValidationErrors {}
242
243impl IntoIterator for ValidationErrors {
244 type Item = ValidationError;
245 type IntoIter = std::vec::IntoIter<ValidationError>;
246
247 fn into_iter(self) -> Self::IntoIter {
248 self.into_vec().into_iter()
249 }
250}
251
252impl<'a> IntoIterator for &'a ValidationErrors {
253 type Item = &'a ValidationError;
254 type IntoIter = Box<dyn Iterator<Item = &'a ValidationError> + 'a>;
255
256 fn into_iter(self) -> Self::IntoIter {
257 Box::new(std::iter::once(&self.first).chain(self.rest.iter()))
258 }
259}
260
261#[derive(Debug, Clone)]
263pub struct ValidatedReleaseConfig {
264 pub per_package: HashMap<String, PackageReleaseConfig>,
266}
267
268struct ParsedPrereleaseCache {
270 specs: HashMap<String, PrereleaseSpec>,
271}
272
273pub struct ReleaseValidator;
281
282impl ReleaseValidator {
283 pub fn validate(
294 cli_input: &ReleaseCliInput,
295 prerelease_state: Option<&PrereleaseState>,
296 graduation_state: Option<&GraduationState>,
297 packages: &[PackageInfo],
298 project_kind: &ProjectKind,
299 ) -> Result<ValidatedReleaseConfig, ValidationErrors> {
300 let mut collector = ValidationErrorCollector::new();
301 let package_names: HashSet<_> = packages.iter().map(|p| p.name.as_str()).collect();
302 let available_packages: Vec<String> = packages.iter().map(|p| p.name.clone()).collect();
303 let package_lookup: HashMap<_, _> = packages.iter().map(|p| (p.name.as_str(), p)).collect();
304
305 Self::validate_packages_exist(
306 cli_input.cli_prerelease.keys().map(String::as_str),
307 &package_names,
308 &available_packages,
309 &mut collector,
310 );
311
312 Self::validate_packages_exist(
313 cli_input.cli_graduate.iter().map(String::as_str),
314 &package_names,
315 &available_packages,
316 &mut collector,
317 );
318
319 let parsed_cache =
320 Self::validate_and_parse_toml_prerelease(prerelease_state, &mut collector);
321
322 Self::validate_prerelease_consistency(cli_input, prerelease_state, &mut collector);
323
324 Self::validate_graduation_not_from_prerelease(
325 cli_input,
326 graduation_state,
327 &package_lookup,
328 &mut collector,
329 );
330
331 Self::validate_graduation_targets(
332 cli_input,
333 graduation_state,
334 &package_lookup,
335 &mut collector,
336 );
337
338 Self::validate_workspace_graduation(cli_input, project_kind, &mut collector);
339
340 if let Some(errors) = collector.into_errors() {
341 Err(errors)
342 } else {
343 Ok(Self::build_config(
344 cli_input,
345 &parsed_cache,
346 graduation_state,
347 packages,
348 ))
349 }
350 }
351
352 fn validate_packages_exist<'a>(
353 names: impl Iterator<Item = &'a str>,
354 valid_names: &HashSet<&str>,
355 available_packages: &[String],
356 collector: &mut ValidationErrorCollector,
357 ) {
358 for name in names {
359 if !valid_names.contains(name) {
360 collector.push(ValidationError::PackageNotFound {
361 name: name.to_string(),
362 available: available_packages.to_vec(),
363 });
364 }
365 }
366 }
367
368 fn validate_prerelease_consistency(
369 cli_input: &ReleaseCliInput,
370 prerelease_state: Option<&PrereleaseState>,
371 collector: &mut ValidationErrorCollector,
372 ) {
373 let Some(state) = prerelease_state else {
374 return;
375 };
376
377 for (pkg, cli_spec) in &cli_input.cli_prerelease {
378 if let Some(toml_tag) = state.get(pkg) {
379 let cli_tag = cli_spec.to_string();
380 if cli_tag != toml_tag {
381 collector.push(ValidationError::ConflictingPrereleaseTag {
382 package: pkg.clone(),
383 cli_tag,
384 toml_tag: toml_tag.to_string(),
385 });
386 }
387 }
388 }
389 }
390
391 fn validate_graduation_not_from_prerelease(
392 cli_input: &ReleaseCliInput,
393 graduation_state: Option<&GraduationState>,
394 package_lookup: &HashMap<&str, &PackageInfo>,
395 collector: &mut ValidationErrorCollector,
396 ) {
397 for pkg_name in &cli_input.cli_graduate {
398 if let Some(pkg) = package_lookup.get(pkg_name.as_str()) {
399 if is_prerelease(&pkg.version) {
400 collector.push(ValidationError::CannotGraduateFromPrerelease {
401 package: pkg_name.clone(),
402 current_version: pkg.version.to_string(),
403 });
404 }
405 }
406 }
407
408 if let Some(state) = graduation_state {
409 for pkg_name in state.iter() {
410 if let Some(pkg) = package_lookup.get(pkg_name) {
411 if is_prerelease(&pkg.version) {
412 collector.push(ValidationError::CannotGraduateFromPrerelease {
413 package: pkg_name.to_string(),
414 current_version: pkg.version.to_string(),
415 });
416 }
417 }
418 }
419 }
420 }
421
422 fn validate_graduation_targets(
423 cli_input: &ReleaseCliInput,
424 graduation_state: Option<&GraduationState>,
425 package_lookup: &HashMap<&str, &PackageInfo>,
426 collector: &mut ValidationErrorCollector,
427 ) {
428 for pkg_name in &cli_input.cli_graduate {
429 if let Some(pkg) = package_lookup.get(pkg_name.as_str()) {
430 if !is_zero_version(&pkg.version) && !is_prerelease(&pkg.version) {
431 collector.push(ValidationError::CannotGraduateStableVersion {
432 package: pkg_name.clone(),
433 version: pkg.version.to_string(),
434 });
435 }
436 }
437 }
438
439 if let Some(state) = graduation_state {
440 for pkg_name in state.iter() {
441 if let Some(pkg) = package_lookup.get(pkg_name) {
442 if !is_zero_version(&pkg.version) && !is_prerelease(&pkg.version) {
443 collector.push(ValidationError::CannotGraduateStableVersion {
444 package: pkg_name.to_string(),
445 version: pkg.version.to_string(),
446 });
447 }
448 }
449 }
450 }
451 }
452
453 fn validate_workspace_graduation(
454 cli_input: &ReleaseCliInput,
455 project_kind: &ProjectKind,
456 collector: &mut ValidationErrorCollector,
457 ) {
458 if *project_kind == ProjectKind::SinglePackage {
459 return;
460 }
461
462 if cli_input.graduate_all && cli_input.cli_graduate.is_empty() {
463 collector.push(ValidationError::GraduateRequiresCratesInWorkspace);
464 }
465 }
466
467 fn validate_and_parse_toml_prerelease(
475 prerelease_state: Option<&PrereleaseState>,
476 collector: &mut ValidationErrorCollector,
477 ) -> ParsedPrereleaseCache {
478 let mut specs = HashMap::new();
479
480 let Some(state) = prerelease_state else {
481 return ParsedPrereleaseCache { specs };
482 };
483
484 for (pkg, tag) in state.iter() {
485 match tag.parse::<PrereleaseSpec>() {
486 Ok(spec) => {
487 specs.insert(pkg.to_string(), spec);
488 }
489 Err(e) => {
490 collector.push(ValidationError::InvalidPrereleaseTag {
491 package: pkg.to_string(),
492 tag: tag.to_string(),
493 reason: e.to_string(),
494 });
495 }
496 }
497 }
498
499 ParsedPrereleaseCache { specs }
500 }
501
502 fn build_config(
508 cli_input: &ReleaseCliInput,
509 parsed_cache: &ParsedPrereleaseCache,
510 graduation_state: Option<&GraduationState>,
511 packages: &[PackageInfo],
512 ) -> ValidatedReleaseConfig {
513 let mut per_package = HashMap::new();
514
515 for (pkg, spec) in &parsed_cache.specs {
516 per_package
517 .entry(pkg.clone())
518 .or_insert_with(PackageReleaseConfig::default)
519 .prerelease = Some(spec.clone());
520 }
521
522 for (pkg, spec) in &cli_input.cli_prerelease {
523 per_package
524 .entry(pkg.clone())
525 .or_insert_with(PackageReleaseConfig::default)
526 .prerelease = Some(spec.clone());
527 }
528
529 if let Some(global) = &cli_input.global_prerelease {
530 for pkg in packages {
531 per_package
532 .entry(pkg.name.clone())
533 .or_insert_with(PackageReleaseConfig::default)
534 .prerelease = Some(global.clone());
535 }
536 }
537
538 if let Some(state) = graduation_state {
539 for pkg in state.iter() {
540 per_package
541 .entry(pkg.to_string())
542 .or_insert_with(PackageReleaseConfig::default)
543 .graduate_zero = true;
544 }
545 }
546
547 for pkg in &cli_input.cli_graduate {
548 per_package
549 .entry(pkg.clone())
550 .or_insert_with(PackageReleaseConfig::default)
551 .graduate_zero = true;
552 }
553
554 if cli_input.graduate_all {
555 for pkg in packages {
556 if is_zero_version(&pkg.version) {
557 per_package
558 .entry(pkg.name.clone())
559 .or_insert_with(PackageReleaseConfig::default)
560 .graduate_zero = true;
561 }
562 }
563 }
564
565 ValidatedReleaseConfig { per_package }
566 }
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572 use std::path::PathBuf;
573
574 fn make_package(name: &str, version: &str) -> PackageInfo {
575 PackageInfo {
576 name: name.to_string(),
577 version: version.parse().expect("valid version"),
578 path: PathBuf::from(format!("/mock/{name}")),
579 }
580 }
581
582 mod prerelease_consistency {
583 use super::*;
584
585 #[test]
586 fn matching_tags_pass() {
587 let packages = vec![make_package("crate-a", "1.0.0")];
588 let mut cli_input = ReleaseCliInput::default();
589 cli_input
590 .cli_prerelease
591 .insert("crate-a".to_string(), PrereleaseSpec::Alpha);
592
593 let mut prerelease_state = PrereleaseState::new();
594 prerelease_state.insert("crate-a".to_string(), "alpha".to_string());
595
596 let result = ReleaseValidator::validate(
597 &cli_input,
598 Some(&prerelease_state),
599 None,
600 &packages,
601 &ProjectKind::SinglePackage,
602 );
603
604 assert!(result.is_ok());
605 }
606
607 #[test]
608 fn conflicting_tags_fail() {
609 let packages = vec![make_package("crate-a", "1.0.0")];
610 let mut cli_input = ReleaseCliInput::default();
611 cli_input
612 .cli_prerelease
613 .insert("crate-a".to_string(), PrereleaseSpec::Beta);
614
615 let mut prerelease_state = PrereleaseState::new();
616 prerelease_state.insert("crate-a".to_string(), "alpha".to_string());
617
618 let result = ReleaseValidator::validate(
619 &cli_input,
620 Some(&prerelease_state),
621 None,
622 &packages,
623 &ProjectKind::SinglePackage,
624 );
625
626 assert!(result.is_err());
627 let errors = result.expect_err("validation should fail");
628 assert_eq!(errors.len(), 1);
629 assert!(matches!(
630 errors.iter().next().expect("at least one error"),
631 ValidationError::ConflictingPrereleaseTag { .. }
632 ));
633 }
634 }
635
636 mod graduation_validation {
637 use super::*;
638
639 #[test]
640 fn cannot_graduate_prerelease_version() {
641 let packages = vec![make_package("crate-a", "1.0.0-alpha.1")];
642 let mut cli_input = ReleaseCliInput::default();
643 cli_input.cli_graduate.insert("crate-a".to_string());
644
645 let result = ReleaseValidator::validate(
646 &cli_input,
647 None,
648 None,
649 &packages,
650 &ProjectKind::SinglePackage,
651 );
652
653 assert!(result.is_err());
654 let errors = result.expect_err("validation should fail");
655 assert!(matches!(
656 errors.iter().next().expect("at least one error"),
657 ValidationError::CannotGraduateFromPrerelease { .. }
658 ));
659 }
660
661 #[test]
662 fn cannot_graduate_stable_version() {
663 let packages = vec![make_package("crate-a", "2.0.0")];
664 let mut cli_input = ReleaseCliInput::default();
665 cli_input.cli_graduate.insert("crate-a".to_string());
666
667 let result = ReleaseValidator::validate(
668 &cli_input,
669 None,
670 None,
671 &packages,
672 &ProjectKind::SinglePackage,
673 );
674
675 assert!(result.is_err());
676 let errors = result.expect_err("validation should fail");
677 assert!(matches!(
678 errors.iter().next().expect("at least one error"),
679 ValidationError::CannotGraduateStableVersion { .. }
680 ));
681 }
682
683 #[test]
684 fn zero_version_graduation_passes() {
685 let packages = vec![make_package("crate-a", "0.5.0")];
686 let mut cli_input = ReleaseCliInput::default();
687 cli_input.cli_graduate.insert("crate-a".to_string());
688
689 let result = ReleaseValidator::validate(
690 &cli_input,
691 None,
692 None,
693 &packages,
694 &ProjectKind::SinglePackage,
695 );
696
697 assert!(result.is_ok());
698 }
699
700 #[test]
701 fn workspace_graduate_requires_crate_names() {
702 let packages = vec![
703 make_package("crate-a", "0.5.0"),
704 make_package("crate-b", "0.3.0"),
705 ];
706 let cli_input = ReleaseCliInput {
707 graduate_all: true,
708 ..Default::default()
709 };
710
711 let result = ReleaseValidator::validate(
712 &cli_input,
713 None,
714 None,
715 &packages,
716 &ProjectKind::VirtualWorkspace,
717 );
718
719 assert!(result.is_err());
720 let errors = result.expect_err("validation should fail");
721 assert!(matches!(
722 errors.iter().next().expect("at least one error"),
723 ValidationError::GraduateRequiresCratesInWorkspace
724 ));
725 }
726
727 #[test]
728 fn single_package_graduate_without_name_passes() {
729 let packages = vec![make_package("my-crate", "0.5.0")];
730 let cli_input = ReleaseCliInput {
731 graduate_all: true,
732 ..Default::default()
733 };
734
735 let result = ReleaseValidator::validate(
736 &cli_input,
737 None,
738 None,
739 &packages,
740 &ProjectKind::SinglePackage,
741 );
742
743 assert!(result.is_ok());
744 }
745 }
746
747 mod package_existence {
748 use super::*;
749
750 #[test]
751 fn unknown_package_in_prerelease_fails() {
752 let packages = vec![make_package("known", "1.0.0")];
753 let mut cli_input = ReleaseCliInput::default();
754 cli_input
755 .cli_prerelease
756 .insert("unknown".to_string(), PrereleaseSpec::Alpha);
757
758 let result = ReleaseValidator::validate(
759 &cli_input,
760 None,
761 None,
762 &packages,
763 &ProjectKind::SinglePackage,
764 );
765
766 assert!(result.is_err());
767 let errors = result.expect_err("validation should fail");
768 assert!(matches!(
769 errors.iter().next().expect("at least one error"),
770 ValidationError::PackageNotFound { .. }
771 ));
772 }
773 }
774
775 mod graduation_with_prerelease {
776 use super::*;
777
778 #[test]
779 fn graduation_with_prerelease_toml_succeeds() {
780 let packages = vec![make_package("crate-a", "0.5.0")];
781 let mut cli_input = ReleaseCliInput::default();
782 cli_input.cli_graduate.insert("crate-a".to_string());
783
784 let mut prerelease_state = PrereleaseState::new();
785 prerelease_state.insert("crate-a".to_string(), "alpha".to_string());
786
787 let result = ReleaseValidator::validate(
788 &cli_input,
789 Some(&prerelease_state),
790 None,
791 &packages,
792 &ProjectKind::SinglePackage,
793 );
794
795 assert!(
796 result.is_ok(),
797 "graduation with prerelease TOML should succeed"
798 );
799 let config = result.expect("validation should pass");
800 let pkg_config = config
801 .per_package
802 .get("crate-a")
803 .expect("crate-a should have config");
804 assert!(pkg_config.graduate_zero, "should be marked for graduation");
805 assert!(
806 matches!(pkg_config.prerelease, Some(PrereleaseSpec::Alpha)),
807 "should have alpha prerelease tag"
808 );
809 }
810 }
811
812 mod multiple_errors {
813 use super::*;
814
815 #[test]
816 fn collects_all_errors() {
817 let packages = vec![make_package("known", "1.0.0")];
818 let mut cli_input = ReleaseCliInput::default();
819 cli_input
820 .cli_prerelease
821 .insert("unknown1".to_string(), PrereleaseSpec::Alpha);
822 cli_input.cli_graduate.insert("unknown2".to_string());
823
824 let result = ReleaseValidator::validate(
825 &cli_input,
826 None,
827 None,
828 &packages,
829 &ProjectKind::SinglePackage,
830 );
831
832 assert!(result.is_err());
833 let errors = result.expect_err("validation should fail");
834 assert_eq!(errors.len(), 2);
835 }
836 }
837
838 mod toml_prerelease_validation {
839 use super::*;
840
841 #[test]
842 fn invalid_prerelease_tag_in_toml_fails() {
843 let packages = vec![make_package("crate-a", "1.0.0")];
844 let cli_input = ReleaseCliInput::default();
845
846 let mut prerelease_state = PrereleaseState::new();
847 prerelease_state.insert("crate-a".to_string(), "not-a-valid-tag!!!".to_string());
848
849 let result = ReleaseValidator::validate(
850 &cli_input,
851 Some(&prerelease_state),
852 None,
853 &packages,
854 &ProjectKind::SinglePackage,
855 );
856
857 assert!(result.is_err());
858 let errors = result.expect_err("validation should fail");
859 assert_eq!(errors.len(), 1);
860 assert!(matches!(
861 errors.iter().next().expect("at least one error"),
862 ValidationError::InvalidPrereleaseTag { .. }
863 ));
864 }
865
866 #[test]
867 fn valid_prerelease_tag_in_toml_passes() {
868 let packages = vec![make_package("crate-a", "1.0.0")];
869 let cli_input = ReleaseCliInput::default();
870
871 let mut prerelease_state = PrereleaseState::new();
872 prerelease_state.insert("crate-a".to_string(), "alpha".to_string());
873
874 let result = ReleaseValidator::validate(
875 &cli_input,
876 Some(&prerelease_state),
877 None,
878 &packages,
879 &ProjectKind::SinglePackage,
880 );
881
882 assert!(result.is_ok());
883 }
884 }
885
886 mod config_building {
887 use super::*;
888
889 #[test]
890 fn builds_config_from_all_sources() {
891 let packages = vec![
892 make_package("crate-a", "0.5.0"),
893 make_package("crate-b", "0.3.0"),
894 make_package("crate-c", "1.0.0"),
895 ];
896
897 let mut cli_input = ReleaseCliInput::default();
898 cli_input
899 .cli_prerelease
900 .insert("crate-a".to_string(), PrereleaseSpec::Beta);
901
902 let mut prerelease_state = PrereleaseState::new();
903 prerelease_state.insert("crate-b".to_string(), "alpha".to_string());
904
905 let mut graduation_state = GraduationState::new();
906 graduation_state.add("crate-a".to_string());
907
908 let result = ReleaseValidator::validate(
909 &cli_input,
910 Some(&prerelease_state),
911 Some(&graduation_state),
912 &packages,
913 &ProjectKind::VirtualWorkspace,
914 );
915
916 assert!(result.is_ok());
917 let config = result.expect("validation should pass");
918
919 let config_a = config
920 .per_package
921 .get("crate-a")
922 .expect("crate-a should have config");
923 assert!(matches!(config_a.prerelease, Some(PrereleaseSpec::Beta)));
924 assert!(config_a.graduate_zero);
925
926 let config_b = config
927 .per_package
928 .get("crate-b")
929 .expect("crate-b should have config");
930 assert!(matches!(config_b.prerelease, Some(PrereleaseSpec::Alpha)));
931 assert!(!config_b.graduate_zero);
932 }
933 }
934
935 mod advanced_error_scenarios {
936 use super::*;
937
938 #[test]
939 fn collects_three_or_more_errors() {
940 let packages = vec![make_package("known", "1.0.0")];
941 let mut cli_input = ReleaseCliInput::default();
942 cli_input
943 .cli_prerelease
944 .insert("unknown1".to_string(), PrereleaseSpec::Alpha);
945 cli_input
946 .cli_prerelease
947 .insert("unknown2".to_string(), PrereleaseSpec::Beta);
948 cli_input.cli_graduate.insert("unknown3".to_string());
949
950 let result = ReleaseValidator::validate(
951 &cli_input,
952 None,
953 None,
954 &packages,
955 &ProjectKind::SinglePackage,
956 );
957
958 assert!(result.is_err());
959 let errors = result.expect_err("validation should fail");
960 assert_eq!(errors.len(), 3, "should collect all three errors");
961 }
962
963 #[test]
964 fn graduation_from_toml_for_prerelease_version_fails() {
965 let packages = vec![make_package("crate-a", "0.5.0-alpha.1")];
966 let cli_input = ReleaseCliInput::default();
967
968 let mut graduation_state = GraduationState::new();
969 graduation_state.add("crate-a".to_string());
970
971 let result = ReleaseValidator::validate(
972 &cli_input,
973 None,
974 Some(&graduation_state),
975 &packages,
976 &ProjectKind::SinglePackage,
977 );
978
979 assert!(result.is_err());
980 let errors = result.expect_err("validation should fail");
981 assert!(matches!(
982 errors.iter().next().expect("at least one error"),
983 ValidationError::CannotGraduateFromPrerelease { .. }
984 ));
985 }
986
987 #[test]
988 fn graduation_from_toml_for_stable_version_fails() {
989 let packages = vec![make_package("crate-a", "2.0.0")];
990 let cli_input = ReleaseCliInput::default();
991
992 let mut graduation_state = GraduationState::new();
993 graduation_state.add("crate-a".to_string());
994
995 let result = ReleaseValidator::validate(
996 &cli_input,
997 None,
998 Some(&graduation_state),
999 &packages,
1000 &ProjectKind::SinglePackage,
1001 );
1002
1003 assert!(result.is_err());
1004 let errors = result.expect_err("validation should fail");
1005 assert!(matches!(
1006 errors.iter().next().expect("at least one error"),
1007 ValidationError::CannotGraduateStableVersion { .. }
1008 ));
1009 }
1010
1011 #[test]
1012 fn graduation_toml_with_prerelease_toml_succeeds() {
1013 let packages = vec![make_package("crate-a", "0.5.0")];
1014 let cli_input = ReleaseCliInput::default();
1015
1016 let mut prerelease_state = PrereleaseState::new();
1017 prerelease_state.insert("crate-a".to_string(), "alpha".to_string());
1018
1019 let mut graduation_state = GraduationState::new();
1020 graduation_state.add("crate-a".to_string());
1021
1022 let result = ReleaseValidator::validate(
1023 &cli_input,
1024 Some(&prerelease_state),
1025 Some(&graduation_state),
1026 &packages,
1027 &ProjectKind::SinglePackage,
1028 );
1029
1030 assert!(
1031 result.is_ok(),
1032 "graduation TOML + prerelease TOML should succeed"
1033 );
1034 let config = result.expect("validation should pass");
1035 let pkg_config = config
1036 .per_package
1037 .get("crate-a")
1038 .expect("crate-a should have config");
1039 assert!(pkg_config.graduate_zero, "should be marked for graduation");
1040 assert!(
1041 matches!(pkg_config.prerelease, Some(PrereleaseSpec::Alpha)),
1042 "should have alpha prerelease tag from TOML"
1043 );
1044 }
1045
1046 #[test]
1047 fn empty_prerelease_tag_in_toml_fails() {
1048 let packages = vec![make_package("crate-a", "1.0.0")];
1049 let cli_input = ReleaseCliInput::default();
1050
1051 let mut prerelease_state = PrereleaseState::new();
1052 prerelease_state.insert("crate-a".to_string(), String::new());
1053
1054 let result = ReleaseValidator::validate(
1055 &cli_input,
1056 Some(&prerelease_state),
1057 None,
1058 &packages,
1059 &ProjectKind::SinglePackage,
1060 );
1061
1062 assert!(result.is_err());
1063 let errors = result.expect_err("validation should fail");
1064 assert!(matches!(
1065 errors.iter().next().expect("at least one error"),
1066 ValidationError::InvalidPrereleaseTag { .. }
1067 ));
1068 }
1069
1070 #[test]
1071 fn global_prerelease_applies_to_all_packages() {
1072 let packages = vec![
1073 make_package("crate-a", "1.0.0"),
1074 make_package("crate-b", "2.0.0"),
1075 ];
1076 let cli_input = ReleaseCliInput {
1077 global_prerelease: Some(PrereleaseSpec::Beta),
1078 ..Default::default()
1079 };
1080
1081 let result = ReleaseValidator::validate(
1082 &cli_input,
1083 None,
1084 None,
1085 &packages,
1086 &ProjectKind::VirtualWorkspace,
1087 );
1088
1089 assert!(result.is_ok());
1090 let config = result.expect("validation should pass");
1091
1092 for pkg in &packages {
1093 let pkg_config = config
1094 .per_package
1095 .get(&pkg.name)
1096 .expect("each package should have config");
1097 assert!(matches!(pkg_config.prerelease, Some(PrereleaseSpec::Beta)));
1098 }
1099 }
1100
1101 #[test]
1102 fn graduate_all_applies_to_zero_versions_only() {
1103 let packages = vec![
1104 make_package("zero-crate", "0.5.0"),
1105 make_package("stable-crate", "1.0.0"),
1106 ];
1107 let cli_input = ReleaseCliInput {
1108 graduate_all: true,
1109 ..Default::default()
1110 };
1111
1112 let result = ReleaseValidator::validate(
1113 &cli_input,
1114 None,
1115 None,
1116 &packages,
1117 &ProjectKind::SinglePackage,
1118 );
1119
1120 assert!(result.is_ok());
1121 let config = result.expect("validation should pass");
1122
1123 let zero_config = config.per_package.get("zero-crate");
1124 assert!(
1125 zero_config.is_some_and(|c| c.graduate_zero),
1126 "zero version should graduate"
1127 );
1128
1129 let stable_config = config.per_package.get("stable-crate");
1130 assert!(
1131 stable_config.is_none() || !stable_config.is_some_and(|c| c.graduate_zero),
1132 "stable version should not graduate"
1133 );
1134 }
1135 }
1136
1137 mod validation_error_display {
1138 use super::*;
1139
1140 #[test]
1141 fn conflicting_prerelease_tag_display() {
1142 let error = ValidationError::ConflictingPrereleaseTag {
1143 package: "my-crate".to_string(),
1144 cli_tag: "beta".to_string(),
1145 toml_tag: "alpha".to_string(),
1146 };
1147
1148 let display = error.to_string();
1149
1150 assert!(display.contains("my-crate"));
1151 assert!(display.contains("beta"));
1152 assert!(display.contains("alpha"));
1153 assert!(display.contains("conflicting"));
1154 }
1155
1156 #[test]
1157 fn conflicting_prerelease_tag_tip() {
1158 let error = ValidationError::ConflictingPrereleaseTag {
1159 package: "my-crate".to_string(),
1160 cli_tag: "beta".to_string(),
1161 toml_tag: "alpha".to_string(),
1162 };
1163
1164 let tip = error.tip();
1165
1166 assert!(tip.contains("cargo changeset manage pre-release"));
1167 assert!(tip.contains("--remove my-crate"));
1168 }
1169
1170 #[test]
1171 fn cannot_graduate_from_prerelease_display() {
1172 let error = ValidationError::CannotGraduateFromPrerelease {
1173 package: "my-crate".to_string(),
1174 current_version: "0.5.0-alpha.1".to_string(),
1175 };
1176
1177 let display = error.to_string();
1178
1179 assert!(display.contains("my-crate"));
1180 assert!(display.contains("prerelease"));
1181 }
1182
1183 #[test]
1184 fn cannot_graduate_from_prerelease_tip() {
1185 let error = ValidationError::CannotGraduateFromPrerelease {
1186 package: "my-crate".to_string(),
1187 current_version: "0.5.0-alpha.1".to_string(),
1188 };
1189
1190 let tip = error.tip();
1191
1192 assert!(tip.contains("release"));
1193 assert!(tip.contains("my-crate"));
1194 }
1195
1196 #[test]
1197 fn graduate_requires_crates_in_workspace_display() {
1198 let error = ValidationError::GraduateRequiresCratesInWorkspace;
1199
1200 let display = error.to_string();
1201
1202 assert!(display.contains("--graduate"));
1203 assert!(display.contains("workspace"));
1204 }
1205
1206 #[test]
1207 fn graduate_requires_crates_in_workspace_tip() {
1208 let error = ValidationError::GraduateRequiresCratesInWorkspace;
1209
1210 let tip = error.tip();
1211
1212 assert!(tip.contains("--graduate"));
1213 }
1214
1215 #[test]
1216 fn package_not_found_display() {
1217 let error = ValidationError::PackageNotFound {
1218 name: "missing".to_string(),
1219 available: vec!["crate-a".to_string(), "crate-b".to_string()],
1220 };
1221
1222 let display = error.to_string();
1223
1224 assert!(display.contains("missing"));
1225 assert!(display.contains("not found"));
1226 }
1227
1228 #[test]
1229 fn package_not_found_tip() {
1230 let error = ValidationError::PackageNotFound {
1231 name: "missing".to_string(),
1232 available: vec!["crate-a".to_string(), "crate-b".to_string()],
1233 };
1234
1235 let tip = error.tip();
1236
1237 assert!(tip.contains("missing"));
1238 assert!(tip.contains("crate-a"));
1239 assert!(tip.contains("crate-b"));
1240 }
1241
1242 #[test]
1243 fn cannot_graduate_stable_version_display() {
1244 let error = ValidationError::CannotGraduateStableVersion {
1245 package: "my-crate".to_string(),
1246 version: "2.0.0".to_string(),
1247 };
1248
1249 let display = error.to_string();
1250
1251 assert!(display.contains("my-crate"));
1252 assert!(display.contains("stable"));
1253 assert!(display.contains("2.0.0"));
1254 }
1255
1256 #[test]
1257 fn cannot_graduate_stable_version_tip() {
1258 let error = ValidationError::CannotGraduateStableVersion {
1259 package: "my-crate".to_string(),
1260 version: "2.0.0".to_string(),
1261 };
1262
1263 let tip = error.tip();
1264
1265 assert!(tip.contains("my-crate"));
1266 assert!(tip.contains("0.x"));
1267 }
1268
1269 #[test]
1270 fn invalid_prerelease_tag_display() {
1271 let error = ValidationError::InvalidPrereleaseTag {
1272 package: "my-crate".to_string(),
1273 tag: "bad.tag".to_string(),
1274 reason: "contains invalid character".to_string(),
1275 };
1276
1277 let display = error.to_string();
1278
1279 assert!(display.contains("my-crate"));
1280 assert!(display.contains("bad.tag"));
1281 assert!(display.contains("invalid"));
1282 }
1283
1284 #[test]
1285 fn invalid_prerelease_tag_tip() {
1286 let error = ValidationError::InvalidPrereleaseTag {
1287 package: "my-crate".to_string(),
1288 tag: "bad.tag".to_string(),
1289 reason: "contains invalid character".to_string(),
1290 };
1291
1292 let tip = error.tip();
1293
1294 assert!(tip.contains("--remove my-crate"));
1295 assert!(tip.contains("re-add"));
1296 }
1297 }
1298
1299 mod validation_errors_collection {
1300 use super::*;
1301
1302 #[test]
1303 fn from_vec_creates_with_single_error() {
1304 let errors = vec![ValidationError::GraduateRequiresCratesInWorkspace];
1305
1306 let collection = ValidationErrors::from_vec(errors);
1307
1308 assert_eq!(collection.len(), 1);
1309 }
1310
1311 #[test]
1312 fn from_vec_creates_with_multiple_errors() {
1313 let errors = vec![
1314 ValidationError::GraduateRequiresCratesInWorkspace,
1315 ValidationError::PackageNotFound {
1316 name: "test".to_string(),
1317 available: vec![],
1318 },
1319 ];
1320
1321 let collection = ValidationErrors::from_vec(errors);
1322
1323 assert_eq!(collection.len(), 2);
1324 }
1325
1326 #[test]
1327 #[should_panic(expected = "at least one error")]
1328 fn from_vec_panics_on_empty() {
1329 let errors: Vec<ValidationError> = vec![];
1330 let _ = ValidationErrors::from_vec(errors);
1331 }
1332
1333 #[test]
1334 fn try_from_vec_returns_none_for_empty() {
1335 let errors: Vec<ValidationError> = vec![];
1336
1337 let result = ValidationErrors::try_from_vec(errors);
1338
1339 assert!(result.is_none());
1340 }
1341
1342 #[test]
1343 fn try_from_vec_returns_some_for_nonempty() {
1344 let errors = vec![ValidationError::GraduateRequiresCratesInWorkspace];
1345
1346 let result = ValidationErrors::try_from_vec(errors);
1347
1348 assert!(result.is_some());
1349 assert_eq!(result.expect("should have errors").len(), 1);
1350 }
1351
1352 #[test]
1353 fn into_vec_returns_all_errors() {
1354 let errors = vec![
1355 ValidationError::GraduateRequiresCratesInWorkspace,
1356 ValidationError::PackageNotFound {
1357 name: "test".to_string(),
1358 available: vec![],
1359 },
1360 ];
1361
1362 let collection = ValidationErrors::from_vec(errors);
1363 let vec = collection.into_vec();
1364
1365 assert_eq!(vec.len(), 2);
1366 }
1367
1368 #[test]
1369 fn iter_yields_all_errors() {
1370 let errors = vec![
1371 ValidationError::GraduateRequiresCratesInWorkspace,
1372 ValidationError::PackageNotFound {
1373 name: "test".to_string(),
1374 available: vec![],
1375 },
1376 ];
1377
1378 let collection = ValidationErrors::from_vec(errors);
1379 let count = collection.iter().count();
1380
1381 assert_eq!(count, 2);
1382 }
1383
1384 #[test]
1385 fn display_shows_all_errors_with_tips() {
1386 let errors = vec![
1387 ValidationError::GraduateRequiresCratesInWorkspace,
1388 ValidationError::PackageNotFound {
1389 name: "test".to_string(),
1390 available: vec!["crate-a".to_string()],
1391 },
1392 ];
1393
1394 let collection = ValidationErrors::from_vec(errors);
1395 let display = collection.to_string();
1396
1397 assert!(display.contains("2 error(s)"));
1398 assert!(display.contains("Tip:"));
1399 assert!(display.contains("--graduate"));
1400 }
1401
1402 #[test]
1403 fn into_iterator_for_owned() {
1404 let errors = vec![ValidationError::GraduateRequiresCratesInWorkspace];
1405 let collection = ValidationErrors::from_vec(errors);
1406
1407 let count = collection.into_iter().count();
1408
1409 assert_eq!(count, 1);
1410 }
1411
1412 #[test]
1413 fn into_iterator_for_ref() {
1414 let errors = vec![ValidationError::GraduateRequiresCratesInWorkspace];
1415 let collection = ValidationErrors::from_vec(errors);
1416
1417 let count = (&collection).into_iter().count();
1418
1419 assert_eq!(count, 1);
1420 }
1421 }
1422}