Skip to main content

changeset_operations/operations/release/
validator.rs

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    /// Returns an actionable tip for resolving this error.
73    #[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    /// # Panics
167    ///
168    /// Panics if the vector is empty. Use `try_from_vec` for a fallible version.
169    #[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    /// # Errors
270    ///
271    /// Returns `ValidationErrors` if any validation rule fails. All errors
272    /// are collected before returning, so the caller receives a complete
273    /// list of issues rather than just the first one.
274    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}