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