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