Skip to main content

changeset_operations/operations/release/
validator.rs

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