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