Skip to main content

changeset_operations/operations/
manage.rs

1use std::path::Path;
2
3use changeset_project::{CargoProject, GraduationState, PrereleaseState};
4use changeset_version::{is_prerelease, is_zero_version};
5
6use crate::Result;
7use crate::error::OperationError;
8use crate::traits::{
9    GraduationAction, GraduationInteractionProvider, MenuSelection, PrereleaseAction,
10    PrereleaseInteractionProvider, ProjectProvider, ReleaseStateIO,
11};
12
13pub struct PrereleaseManageOperation<P, S, I> {
14    project_provider: P,
15    release_state_io: S,
16    interaction_provider: I,
17}
18
19impl<P, S, I> PrereleaseManageOperation<P, S, I>
20where
21    P: ProjectProvider,
22    S: ReleaseStateIO,
23    I: PrereleaseInteractionProvider + GraduationInteractionProvider,
24{
25    pub fn new(project_provider: P, release_state_io: S, interaction_provider: I) -> Self {
26        Self {
27            project_provider,
28            release_state_io,
29            interaction_provider,
30        }
31    }
32
33    /// # Errors
34    ///
35    /// Returns an error if project discovery, state loading, or interaction fails.
36    pub fn execute(&self, start_path: &Path) -> Result<Vec<PrereleaseEvent>> {
37        let project = self.project_provider.discover_project(start_path)?;
38        let (root_config, _) = self.project_provider.load_configs(&project)?;
39        let changeset_dir = project.root().join(root_config.changeset_dir());
40
41        let mut prerelease_state = self
42            .release_state_io
43            .load_prerelease_state(&changeset_dir)?
44            .unwrap_or_default();
45
46        let mut graduation_state = self
47            .release_state_io
48            .load_graduation_state(&changeset_dir)?
49            .unwrap_or_default();
50
51        let mut events = Vec::new();
52
53        loop {
54            events.push(PrereleaseEvent::DisplayState(
55                prerelease_state
56                    .iter()
57                    .map(|(k, v)| (k.to_string(), v.to_string()))
58                    .collect(),
59            ));
60
61            let action = self.interaction_provider.select_prerelease_action()?;
62
63            match action {
64                MenuSelection::Selected(PrereleaseAction::Add) => {
65                    self.handle_add(&project, &changeset_dir, &mut prerelease_state, &mut events)?;
66                }
67                MenuSelection::Selected(PrereleaseAction::Remove) => {
68                    self.handle_remove(&changeset_dir, &mut prerelease_state, &mut events)?;
69                }
70                MenuSelection::Selected(PrereleaseAction::Graduate) => {
71                    self.handle_graduate(
72                        &project,
73                        &changeset_dir,
74                        &mut prerelease_state,
75                        &mut graduation_state,
76                        &mut events,
77                    )?;
78                }
79                MenuSelection::Selected(PrereleaseAction::Done) | MenuSelection::Cancelled => {
80                    break;
81                }
82            }
83        }
84
85        Ok(events)
86    }
87
88    fn handle_add(
89        &self,
90        project: &CargoProject,
91        changeset_dir: &Path,
92        prerelease_state: &mut PrereleaseState,
93        events: &mut Vec<PrereleaseEvent>,
94    ) -> Result<()> {
95        let available: Vec<_> = project
96            .packages()
97            .iter()
98            .filter(|p| !prerelease_state.contains(&p.name))
99            .collect();
100
101        if available.is_empty() {
102            events.push(PrereleaseEvent::AllPackagesInPrerelease);
103            return Ok(());
104        }
105
106        let selection = self
107            .interaction_provider
108            .select_package_for_prerelease(&available)?;
109
110        let MenuSelection::Selected(index) = selection else {
111            return Ok(());
112        };
113
114        let crate_name = &available[index].name;
115        let tag = self.interaction_provider.get_prerelease_tag()?;
116
117        validate_prerelease_tag(&tag)?;
118
119        prerelease_state.insert(crate_name.clone(), tag.clone());
120        self.release_state_io
121            .save_prerelease_state(changeset_dir, prerelease_state)?;
122        events.push(PrereleaseEvent::Added {
123            crate_name: crate_name.clone(),
124            tag,
125        });
126
127        Ok(())
128    }
129
130    fn handle_remove(
131        &self,
132        changeset_dir: &Path,
133        prerelease_state: &mut PrereleaseState,
134        events: &mut Vec<PrereleaseEvent>,
135    ) -> Result<()> {
136        if prerelease_state.is_empty() {
137            events.push(PrereleaseEvent::NoPrereleasePackages);
138            return Ok(());
139        }
140
141        let mut items: Vec<_> = prerelease_state
142            .iter()
143            .map(|(name, tag)| (name.to_string(), tag.to_string()))
144            .collect();
145        items.sort_by(|a, b| a.0.cmp(&b.0));
146
147        let refs: Vec<(&str, &str)> = items
148            .iter()
149            .map(|(n, t)| (n.as_str(), t.as_str()))
150            .collect();
151        let selection = self
152            .interaction_provider
153            .select_package_to_remove_prerelease(&refs)?;
154
155        let MenuSelection::Selected(index) = selection else {
156            return Ok(());
157        };
158
159        let crate_name = items[index].0.clone();
160        let _ = prerelease_state.remove(&crate_name);
161        self.release_state_io
162            .save_prerelease_state(changeset_dir, prerelease_state)?;
163        events.push(PrereleaseEvent::Removed { crate_name });
164
165        Ok(())
166    }
167
168    fn handle_graduate(
169        &self,
170        project: &CargoProject,
171        changeset_dir: &Path,
172        prerelease_state: &mut PrereleaseState,
173        graduation_state: &mut GraduationState,
174        events: &mut Vec<PrereleaseEvent>,
175    ) -> Result<()> {
176        let eligible: Vec<_> = project
177            .packages()
178            .iter()
179            .filter(|p| is_zero_version(&p.version) && !is_prerelease(&p.version))
180            .collect();
181
182        if eligible.is_empty() {
183            events.push(PrereleaseEvent::NoEligibleForGraduation);
184            return Ok(());
185        }
186
187        let selection = self
188            .interaction_provider
189            .select_package_for_graduation(&eligible)?;
190
191        let MenuSelection::Selected(index) = selection else {
192            return Ok(());
193        };
194
195        let crate_name = &eligible[index].name;
196
197        if prerelease_state.remove(crate_name).is_some() {
198            self.release_state_io
199                .save_prerelease_state(changeset_dir, prerelease_state)?;
200        }
201
202        graduation_state.add(crate_name.clone());
203        self.release_state_io
204            .save_graduation_state(changeset_dir, graduation_state)?;
205        events.push(PrereleaseEvent::MovedToGraduation {
206            crate_name: crate_name.clone(),
207        });
208
209        Ok(())
210    }
211}
212
213pub struct GraduationManageOperation<P, S, I> {
214    project_provider: P,
215    release_state_io: S,
216    interaction_provider: I,
217}
218
219impl<P, S, I> GraduationManageOperation<P, S, I>
220where
221    P: ProjectProvider,
222    S: ReleaseStateIO,
223    I: GraduationInteractionProvider,
224{
225    pub fn new(project_provider: P, release_state_io: S, interaction_provider: I) -> Self {
226        Self {
227            project_provider,
228            release_state_io,
229            interaction_provider,
230        }
231    }
232
233    /// # Errors
234    ///
235    /// Returns an error if project discovery, state loading, or interaction fails.
236    pub fn execute(&self, start_path: &Path) -> Result<Vec<GraduationEvent>> {
237        let project = self.project_provider.discover_project(start_path)?;
238        let (root_config, _) = self.project_provider.load_configs(&project)?;
239        let changeset_dir = project.root().join(root_config.changeset_dir());
240
241        let mut state = self
242            .release_state_io
243            .load_graduation_state(&changeset_dir)?
244            .unwrap_or_default();
245
246        let mut events = Vec::new();
247
248        loop {
249            events.push(GraduationEvent::DisplayState(
250                state.iter().map(str::to_string).collect(),
251            ));
252
253            let action = self.interaction_provider.select_graduation_action()?;
254
255            match action {
256                MenuSelection::Selected(GraduationAction::Add) => {
257                    let eligible: Vec<_> = project
258                        .packages()
259                        .iter()
260                        .filter(|p| {
261                            is_zero_version(&p.version)
262                                && !is_prerelease(&p.version)
263                                && !state.contains(&p.name)
264                        })
265                        .collect();
266
267                    if eligible.is_empty() {
268                        events.push(GraduationEvent::NoEligibleForGraduation);
269                        continue;
270                    }
271
272                    let selection = self
273                        .interaction_provider
274                        .select_package_for_graduation(&eligible)?;
275
276                    let MenuSelection::Selected(index) = selection else {
277                        continue;
278                    };
279
280                    let crate_name = &eligible[index].name;
281                    state.add(crate_name.clone());
282                    self.release_state_io
283                        .save_graduation_state(&changeset_dir, &state)?;
284                    events.push(GraduationEvent::Added {
285                        crate_name: crate_name.clone(),
286                    });
287                }
288                MenuSelection::Selected(GraduationAction::Remove) => {
289                    if state.is_empty() {
290                        events.push(GraduationEvent::NoGraduationPackages);
291                        continue;
292                    }
293
294                    let mut items: Vec<String> = state.iter().map(str::to_string).collect();
295                    items.sort();
296
297                    let selection = self
298                        .interaction_provider
299                        .select_package_to_remove_graduation(&items)?;
300
301                    let MenuSelection::Selected(index) = selection else {
302                        continue;
303                    };
304
305                    let crate_name = &items[index];
306                    let _ = state.remove(crate_name);
307                    self.release_state_io
308                        .save_graduation_state(&changeset_dir, &state)?;
309                    events.push(GraduationEvent::Removed {
310                        crate_name: crate_name.clone(),
311                    });
312                }
313                MenuSelection::Selected(GraduationAction::Done) | MenuSelection::Cancelled => {
314                    break;
315                }
316            }
317        }
318
319        Ok(events)
320    }
321}
322
323pub struct PrereleaseDirectOperation<P, S> {
324    project_provider: P,
325    release_state_io: S,
326}
327
328impl<P, S> PrereleaseDirectOperation<P, S>
329where
330    P: ProjectProvider,
331    S: ReleaseStateIO,
332{
333    pub fn new(project_provider: P, release_state_io: S) -> Self {
334        Self {
335            project_provider,
336            release_state_io,
337        }
338    }
339
340    /// # Errors
341    ///
342    /// Returns an error if project discovery, state loading, validation, or persistence fails.
343    pub fn execute(
344        &self,
345        start_path: &Path,
346        input: &PrereleaseDirectInput,
347    ) -> Result<Vec<PrereleaseEvent>> {
348        let project = self.project_provider.discover_project(start_path)?;
349        let (root_config, _) = self.project_provider.load_configs(&project)?;
350        let changeset_dir = project.root().join(root_config.changeset_dir());
351
352        let mut prerelease_state = self
353            .release_state_io
354            .load_prerelease_state(&changeset_dir)?
355            .unwrap_or_default();
356
357        let mut graduation_state = self
358            .release_state_io
359            .load_graduation_state(&changeset_dir)?
360            .unwrap_or_default();
361
362        let mut events = Vec::new();
363        let mut modified_prerelease = false;
364        let mut modified_graduation = false;
365
366        for entry in &input.add {
367            let (crate_name, tag) = parse_prerelease_entry(entry)?;
368            validate_package_exists(&project, &crate_name)?;
369            validate_prerelease_tag(&tag)?;
370
371            prerelease_state.insert(crate_name.clone(), tag.clone());
372            modified_prerelease = true;
373            events.push(PrereleaseEvent::Added { crate_name, tag });
374        }
375
376        for crate_name in &input.remove {
377            if prerelease_state.remove(crate_name).is_some() {
378                modified_prerelease = true;
379                events.push(PrereleaseEvent::Removed {
380                    crate_name: crate_name.clone(),
381                });
382            }
383        }
384
385        for crate_name in &input.graduate {
386            validate_package_exists(&project, crate_name)?;
387            validate_can_graduate(&project, crate_name)?;
388
389            if prerelease_state.remove(crate_name).is_some() {
390                modified_prerelease = true;
391            }
392
393            graduation_state.add(crate_name.clone());
394            modified_graduation = true;
395            events.push(PrereleaseEvent::MovedToGraduation {
396                crate_name: crate_name.clone(),
397            });
398        }
399
400        if modified_prerelease {
401            self.release_state_io
402                .save_prerelease_state(&changeset_dir, &prerelease_state)?;
403        }
404        if modified_graduation {
405            self.release_state_io
406                .save_graduation_state(&changeset_dir, &graduation_state)?;
407        }
408
409        if input.list {
410            events.push(PrereleaseEvent::DisplayState(
411                prerelease_state
412                    .iter()
413                    .map(|(k, v)| (k.to_string(), v.to_string()))
414                    .collect(),
415            ));
416        }
417
418        Ok(events)
419    }
420}
421
422pub struct PrereleaseDirectInput {
423    add: Vec<String>,
424    remove: Vec<String>,
425    graduate: Vec<String>,
426    list: bool,
427}
428
429impl PrereleaseDirectInput {
430    #[must_use]
431    pub fn new(add: Vec<String>, remove: Vec<String>, graduate: Vec<String>, list: bool) -> Self {
432        Self {
433            add,
434            remove,
435            graduate,
436            list,
437        }
438    }
439}
440
441pub struct GraduationDirectOperation<P, S> {
442    project_provider: P,
443    release_state_io: S,
444}
445
446impl<P, S> GraduationDirectOperation<P, S>
447where
448    P: ProjectProvider,
449    S: ReleaseStateIO,
450{
451    pub fn new(project_provider: P, release_state_io: S) -> Self {
452        Self {
453            project_provider,
454            release_state_io,
455        }
456    }
457
458    /// # Errors
459    ///
460    /// Returns an error if project discovery, state loading, validation, or persistence fails.
461    pub fn execute(
462        &self,
463        start_path: &Path,
464        input: &GraduationDirectInput,
465    ) -> Result<Vec<GraduationEvent>> {
466        let project = self.project_provider.discover_project(start_path)?;
467        let (root_config, _) = self.project_provider.load_configs(&project)?;
468        let changeset_dir = project.root().join(root_config.changeset_dir());
469
470        let mut state = self
471            .release_state_io
472            .load_graduation_state(&changeset_dir)?
473            .unwrap_or_default();
474
475        let mut events = Vec::new();
476        let mut modified = false;
477
478        for crate_name in &input.add {
479            validate_package_exists(&project, crate_name)?;
480            validate_can_graduate(&project, crate_name)?;
481
482            state.add(crate_name.clone());
483            modified = true;
484            events.push(GraduationEvent::Added {
485                crate_name: crate_name.clone(),
486            });
487        }
488
489        for crate_name in &input.remove {
490            if state.remove(crate_name) {
491                modified = true;
492                events.push(GraduationEvent::Removed {
493                    crate_name: crate_name.clone(),
494                });
495            }
496        }
497
498        if modified {
499            self.release_state_io
500                .save_graduation_state(&changeset_dir, &state)?;
501        }
502
503        if input.list {
504            events.push(GraduationEvent::DisplayState(
505                state.iter().map(str::to_string).collect(),
506            ));
507        }
508
509        Ok(events)
510    }
511}
512
513pub struct GraduationDirectInput {
514    add: Vec<String>,
515    remove: Vec<String>,
516    list: bool,
517}
518
519impl GraduationDirectInput {
520    #[must_use]
521    pub fn new(add: Vec<String>, remove: Vec<String>, list: bool) -> Self {
522        Self { add, remove, list }
523    }
524}
525
526#[derive(Debug, Clone, PartialEq, Eq)]
527pub enum PrereleaseEvent {
528    DisplayState(Vec<(String, String)>),
529    Added { crate_name: String, tag: String },
530    Removed { crate_name: String },
531    MovedToGraduation { crate_name: String },
532    AllPackagesInPrerelease,
533    NoPrereleasePackages,
534    NoEligibleForGraduation,
535}
536
537#[derive(Debug, Clone, PartialEq, Eq)]
538pub enum GraduationEvent {
539    DisplayState(Vec<String>),
540    Added { crate_name: String },
541    Removed { crate_name: String },
542    NoEligibleForGraduation,
543    NoGraduationPackages,
544}
545
546fn parse_prerelease_entry(input: &str) -> Result<(String, String)> {
547    let Some((crate_name, tag)) = input.split_once(':') else {
548        return Err(OperationError::InvalidPrereleaseFormat {
549            input: input.to_string(),
550        });
551    };
552
553    if crate_name.is_empty() || tag.is_empty() {
554        return Err(OperationError::InvalidPrereleaseFormat {
555            input: input.to_string(),
556        });
557    }
558
559    Ok((crate_name.to_string(), tag.to_string()))
560}
561
562fn validate_prerelease_tag(tag: &str) -> Result<()> {
563    crate::error::parse_prerelease_tag(tag)?;
564    Ok(())
565}
566
567fn validate_package_exists(project: &CargoProject, name: &str) -> Result<()> {
568    if !project.packages().iter().any(|p| p.name == name) {
569        return Err(OperationError::PackageNotFound {
570            name: name.to_string(),
571        });
572    }
573    Ok(())
574}
575
576fn validate_can_graduate(project: &CargoProject, name: &str) -> Result<()> {
577    let package = project
578        .packages()
579        .iter()
580        .find(|p| p.name == name)
581        .ok_or_else(|| OperationError::PackageNotFound {
582            name: name.to_string(),
583        })?;
584
585    if is_prerelease(&package.version) {
586        return Err(OperationError::CannotGraduatePrerelease {
587            package: name.to_string(),
588            version: package.version.clone(),
589        });
590    }
591
592    if !is_zero_version(&package.version) {
593        return Err(OperationError::CannotGraduateStable {
594            package: name.to_string(),
595            version: package.version.clone(),
596        });
597    }
598
599    Ok(())
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use changeset_core::PackageInfo;
606    use changeset_project::{CargoProject, ProjectKind};
607    use std::path::PathBuf;
608
609    fn make_project(packages: Vec<(&str, &str)>) -> CargoProject {
610        CargoProject::new(
611            PathBuf::from("/mock/project"),
612            ProjectKind::VirtualWorkspace,
613            packages
614                .into_iter()
615                .map(|(name, version)| PackageInfo {
616                    name: name.to_string(),
617                    version: version.parse().expect("valid version"),
618                    path: PathBuf::from(format!("/mock/project/crates/{name}")),
619                })
620                .collect(),
621        )
622    }
623
624    mod parse_prerelease_entry_tests {
625        use super::*;
626
627        #[test]
628        fn parses_valid_format() {
629            let result = parse_prerelease_entry("my-crate:alpha");
630
631            assert!(result.is_ok());
632            let (name, tag) = result.expect("should parse");
633            assert_eq!(name, "my-crate");
634            assert_eq!(tag, "alpha");
635        }
636
637        #[test]
638        fn parses_custom_tag() {
639            let result = parse_prerelease_entry("crate-name:nightly");
640
641            assert!(result.is_ok());
642            let (name, tag) = result.expect("should parse");
643            assert_eq!(name, "crate-name");
644            assert_eq!(tag, "nightly");
645        }
646
647        #[test]
648        fn rejects_missing_colon() {
649            let result = parse_prerelease_entry("no-colon-here");
650
651            assert!(result.is_err());
652            assert!(matches!(
653                result.expect_err("should fail"),
654                OperationError::InvalidPrereleaseFormat { .. }
655            ));
656        }
657
658        #[test]
659        fn rejects_empty_crate_name() {
660            let result = parse_prerelease_entry(":alpha");
661
662            assert!(result.is_err());
663            assert!(matches!(
664                result.expect_err("should fail"),
665                OperationError::InvalidPrereleaseFormat { .. }
666            ));
667        }
668
669        #[test]
670        fn rejects_empty_tag() {
671            let result = parse_prerelease_entry("crate-name:");
672
673            assert!(result.is_err());
674            assert!(matches!(
675                result.expect_err("should fail"),
676                OperationError::InvalidPrereleaseFormat { .. }
677            ));
678        }
679
680        #[test]
681        fn handles_multiple_colons() {
682            let result = parse_prerelease_entry("crate:tag:extra");
683
684            assert!(result.is_ok());
685            let (name, tag) = result.expect("should parse");
686            assert_eq!(name, "crate");
687            assert_eq!(tag, "tag:extra");
688        }
689    }
690
691    mod validate_prerelease_tag_tests {
692        use super::*;
693
694        #[test]
695        fn accepts_alpha() {
696            assert!(validate_prerelease_tag("alpha").is_ok());
697        }
698
699        #[test]
700        fn accepts_beta() {
701            assert!(validate_prerelease_tag("beta").is_ok());
702        }
703
704        #[test]
705        fn accepts_rc() {
706            assert!(validate_prerelease_tag("rc").is_ok());
707        }
708
709        #[test]
710        fn accepts_custom_alphanumeric() {
711            assert!(validate_prerelease_tag("nightly").is_ok());
712            assert!(validate_prerelease_tag("dev123").is_ok());
713        }
714
715        #[test]
716        fn accepts_hyphenated_tags() {
717            assert!(validate_prerelease_tag("pre-release").is_ok());
718        }
719
720        #[test]
721        fn rejects_empty() {
722            let result = validate_prerelease_tag("");
723
724            assert!(result.is_err());
725        }
726
727        #[test]
728        fn rejects_invalid_characters() {
729            let result = validate_prerelease_tag("alpha.1");
730
731            assert!(result.is_err());
732        }
733
734        #[test]
735        fn rejects_spaces() {
736            let result = validate_prerelease_tag("alpha 1");
737
738            assert!(result.is_err());
739            assert!(matches!(
740                result.expect_err("should fail"),
741                OperationError::InvalidPrereleaseTag { .. }
742            ));
743        }
744
745        #[test]
746        fn rejects_underscores() {
747            let result = validate_prerelease_tag("alpha_1");
748
749            assert!(result.is_err());
750        }
751    }
752
753    mod validate_package_exists_tests {
754        use super::*;
755
756        #[test]
757        fn succeeds_for_existing_package() {
758            let project = make_project(vec![("crate-a", "1.0.0"), ("crate-b", "2.0.0")]);
759
760            let result = validate_package_exists(&project, "crate-a");
761
762            assert!(result.is_ok());
763        }
764
765        #[test]
766        fn fails_for_unknown_package() {
767            let project = make_project(vec![("crate-a", "1.0.0")]);
768
769            let result = validate_package_exists(&project, "nonexistent");
770
771            assert!(result.is_err());
772            let err = result.expect_err("should fail");
773            assert!(matches!(err, OperationError::PackageNotFound { .. }));
774            assert!(err.to_string().contains("nonexistent"));
775        }
776
777        #[test]
778        fn fails_for_empty_project() {
779            let project = make_project(vec![]);
780
781            let result = validate_package_exists(&project, "any-crate");
782
783            assert!(result.is_err());
784            assert!(matches!(
785                result.expect_err("should fail"),
786                OperationError::PackageNotFound { .. }
787            ));
788        }
789    }
790
791    mod validate_can_graduate_tests {
792        use super::*;
793
794        #[test]
795        fn succeeds_for_zero_stable_version() {
796            let project = make_project(vec![("crate-a", "0.5.0")]);
797
798            let result = validate_can_graduate(&project, "crate-a");
799
800            assert!(result.is_ok());
801        }
802
803        #[test]
804        fn fails_for_prerelease_version() {
805            let project = make_project(vec![("crate-a", "0.5.0-alpha.1")]);
806
807            let result = validate_can_graduate(&project, "crate-a");
808
809            assert!(result.is_err());
810            let err = result.expect_err("should fail");
811            assert!(matches!(
812                err,
813                OperationError::CannotGraduatePrerelease { .. }
814            ));
815            assert!(err.to_string().contains("crate-a"));
816            assert!(err.to_string().contains("prerelease"));
817        }
818
819        #[test]
820        fn fails_for_stable_version_1_0_0() {
821            let project = make_project(vec![("crate-a", "1.0.0")]);
822
823            let result = validate_can_graduate(&project, "crate-a");
824
825            assert!(result.is_err());
826            let err = result.expect_err("should fail");
827            assert!(matches!(err, OperationError::CannotGraduateStable { .. }));
828            assert!(err.to_string().contains("stable"));
829        }
830
831        #[test]
832        fn fails_for_stable_version_above_1() {
833            let project = make_project(vec![("crate-a", "2.5.3")]);
834
835            let result = validate_can_graduate(&project, "crate-a");
836
837            assert!(result.is_err());
838            assert!(matches!(
839                result.expect_err("should fail"),
840                OperationError::CannotGraduateStable { .. }
841            ));
842        }
843
844        #[test]
845        fn fails_for_unknown_package() {
846            let project = make_project(vec![("crate-a", "0.5.0")]);
847
848            let result = validate_can_graduate(&project, "nonexistent");
849
850            assert!(result.is_err());
851            assert!(matches!(
852                result.expect_err("should fail"),
853                OperationError::PackageNotFound { .. }
854            ));
855        }
856
857        #[test]
858        fn fails_for_zero_prerelease_version() {
859            let project = make_project(vec![("crate-a", "0.1.0-beta.1")]);
860
861            let result = validate_can_graduate(&project, "crate-a");
862
863            assert!(result.is_err());
864            assert!(matches!(
865                result.expect_err("should fail"),
866                OperationError::CannotGraduatePrerelease { .. }
867            ));
868        }
869    }
870
871    mod prerelease_operation {
872        use super::*;
873        use crate::mocks::{
874            MockManageInteractionProvider, MockProjectProvider, MockReleaseStateIO,
875        };
876        use std::sync::Arc;
877
878        #[test]
879        fn exits_on_done_action() {
880            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "0.1.0")]);
881            let release_state_io = Arc::new(MockReleaseStateIO::new());
882            let interaction = Arc::new(
883                MockManageInteractionProvider::new()
884                    .with_prerelease_actions(vec![MenuSelection::Selected(PrereleaseAction::Done)]),
885            );
886
887            let operation = PrereleaseManageOperation::new(
888                project_provider,
889                Arc::clone(&release_state_io),
890                Arc::clone(&interaction),
891            );
892
893            let events = operation
894                .execute(std::path::Path::new("/any"))
895                .expect("prerelease manage operation should execute without error");
896
897            assert_eq!(events.len(), 1);
898            assert!(
899                events
900                    .iter()
901                    .any(|e| matches!(e, PrereleaseEvent::DisplayState(_)))
902            );
903        }
904
905        #[test]
906        fn exits_on_cancelled() {
907            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "0.1.0")]);
908            let release_state_io = Arc::new(MockReleaseStateIO::new());
909            let interaction = Arc::new(
910                MockManageInteractionProvider::new()
911                    .with_prerelease_actions(vec![MenuSelection::Cancelled]),
912            );
913
914            let operation = PrereleaseManageOperation::new(
915                project_provider,
916                Arc::clone(&release_state_io),
917                Arc::clone(&interaction),
918            );
919
920            let events = operation
921                .execute(std::path::Path::new("/any"))
922                .expect("prerelease manage operation should execute without error");
923
924            assert_eq!(events.len(), 1);
925        }
926
927        #[test]
928        fn adds_package_to_prerelease() {
929            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "0.1.0")]);
930            let release_state_io = Arc::new(MockReleaseStateIO::new());
931            let interaction = Arc::new(
932                MockManageInteractionProvider::new()
933                    .with_prerelease_actions(vec![
934                        MenuSelection::Selected(PrereleaseAction::Add),
935                        MenuSelection::Selected(PrereleaseAction::Done),
936                    ])
937                    .with_package_selections(vec![MenuSelection::Selected(0)])
938                    .with_prerelease_tags(vec!["alpha".to_string()]),
939            );
940
941            let operation = PrereleaseManageOperation::new(
942                project_provider,
943                Arc::clone(&release_state_io),
944                Arc::clone(&interaction),
945            );
946
947            let events = operation
948                .execute(std::path::Path::new("/any"))
949                .expect("prerelease manage operation should execute without error");
950
951            assert!(events.contains(&PrereleaseEvent::Added {
952                crate_name: "crate-a".to_string(),
953                tag: "alpha".to_string(),
954            }));
955
956            let prerelease_state = release_state_io
957                .get_prerelease_state()
958                .expect("prerelease state should be saved");
959            assert!(prerelease_state.contains("crate-a"));
960            assert_eq!(prerelease_state.get("crate-a"), Some("alpha"));
961        }
962
963        #[test]
964        fn removes_package_from_prerelease() {
965            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "1.0.0")]);
966            let release_state_io = Arc::new(MockReleaseStateIO::new().with_prerelease_state({
967                let mut state = PrereleaseState::default();
968                state.insert("crate-a".to_string(), "alpha".to_string());
969                state
970            }));
971            let interaction = Arc::new(
972                MockManageInteractionProvider::new()
973                    .with_prerelease_actions(vec![
974                        MenuSelection::Selected(PrereleaseAction::Remove),
975                        MenuSelection::Selected(PrereleaseAction::Done),
976                    ])
977                    .with_remove_prerelease_selections(vec![MenuSelection::Selected(0)]),
978            );
979
980            let operation = PrereleaseManageOperation::new(
981                project_provider,
982                Arc::clone(&release_state_io),
983                Arc::clone(&interaction),
984            );
985
986            let events = operation
987                .execute(std::path::Path::new("/any"))
988                .expect("prerelease manage operation should execute without error");
989
990            assert!(events.contains(&PrereleaseEvent::Removed {
991                crate_name: "crate-a".to_string(),
992            }));
993            match release_state_io.get_prerelease_state() {
994                None => {}
995                Some(state) => assert!(!state.contains("crate-a")),
996            }
997        }
998
999        #[test]
1000        fn reports_no_prerelease_packages_on_remove_when_empty() {
1001            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "1.0.0")]);
1002            let release_state_io = Arc::new(MockReleaseStateIO::new());
1003            let interaction = Arc::new(
1004                MockManageInteractionProvider::new().with_prerelease_actions(vec![
1005                    MenuSelection::Selected(PrereleaseAction::Remove),
1006                    MenuSelection::Selected(PrereleaseAction::Done),
1007                ]),
1008            );
1009
1010            let operation = PrereleaseManageOperation::new(
1011                project_provider,
1012                Arc::clone(&release_state_io),
1013                Arc::clone(&interaction),
1014            );
1015
1016            let events = operation
1017                .execute(std::path::Path::new("/any"))
1018                .expect("prerelease manage operation should execute without error");
1019
1020            assert!(events.contains(&PrereleaseEvent::NoPrereleasePackages));
1021        }
1022
1023        #[test]
1024        fn moves_package_to_graduation() {
1025            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "0.1.0")]);
1026            let release_state_io = Arc::new(MockReleaseStateIO::new().with_prerelease_state({
1027                let mut state = PrereleaseState::default();
1028                state.insert("crate-a".to_string(), "alpha".to_string());
1029                state
1030            }));
1031            let interaction = Arc::new(
1032                MockManageInteractionProvider::new()
1033                    .with_prerelease_actions(vec![
1034                        MenuSelection::Selected(PrereleaseAction::Graduate),
1035                        MenuSelection::Selected(PrereleaseAction::Done),
1036                    ])
1037                    .with_graduation_selections(vec![MenuSelection::Selected(0)]),
1038            );
1039
1040            let operation = PrereleaseManageOperation::new(
1041                project_provider,
1042                Arc::clone(&release_state_io),
1043                Arc::clone(&interaction),
1044            );
1045
1046            let events = operation
1047                .execute(std::path::Path::new("/any"))
1048                .expect("prerelease manage operation should execute without error");
1049
1050            assert!(events.contains(&PrereleaseEvent::MovedToGraduation {
1051                crate_name: "crate-a".to_string(),
1052            }));
1053            let graduation_state = release_state_io
1054                .get_graduation_state()
1055                .expect("graduation state should be saved");
1056            assert!(graduation_state.contains("crate-a"));
1057            match release_state_io.get_prerelease_state() {
1058                None => {}
1059                Some(state) => assert!(!state.contains("crate-a")),
1060            }
1061        }
1062
1063        #[test]
1064        fn reports_no_eligible_for_graduation_when_all_stable() {
1065            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "1.0.0")]);
1066            let release_state_io = Arc::new(MockReleaseStateIO::new());
1067            let interaction = Arc::new(
1068                MockManageInteractionProvider::new().with_prerelease_actions(vec![
1069                    MenuSelection::Selected(PrereleaseAction::Graduate),
1070                    MenuSelection::Selected(PrereleaseAction::Done),
1071                ]),
1072            );
1073
1074            let operation = PrereleaseManageOperation::new(
1075                project_provider,
1076                Arc::clone(&release_state_io),
1077                Arc::clone(&interaction),
1078            );
1079
1080            let events = operation
1081                .execute(std::path::Path::new("/any"))
1082                .expect("prerelease manage operation should execute without error");
1083
1084            assert!(events.contains(&PrereleaseEvent::NoEligibleForGraduation));
1085        }
1086
1087        #[test]
1088        fn cancels_add_when_package_selection_cancelled() {
1089            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "0.1.0")]);
1090            let release_state_io = Arc::new(MockReleaseStateIO::new());
1091            let interaction = Arc::new(
1092                MockManageInteractionProvider::new()
1093                    .with_prerelease_actions(vec![
1094                        MenuSelection::Selected(PrereleaseAction::Add),
1095                        MenuSelection::Selected(PrereleaseAction::Done),
1096                    ])
1097                    .with_package_selections(vec![MenuSelection::Cancelled]),
1098            );
1099
1100            let operation = PrereleaseManageOperation::new(
1101                project_provider,
1102                Arc::clone(&release_state_io),
1103                Arc::clone(&interaction),
1104            );
1105
1106            let events = operation
1107                .execute(std::path::Path::new("/any"))
1108                .expect("prerelease manage operation should execute without error");
1109
1110            assert!(
1111                !events
1112                    .iter()
1113                    .any(|e| matches!(e, PrereleaseEvent::Added { .. }))
1114            );
1115            assert!(release_state_io.get_prerelease_state().is_none());
1116        }
1117
1118        #[test]
1119        fn reports_all_packages_in_prerelease() {
1120            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "0.1.0")]);
1121            let release_state_io = Arc::new(MockReleaseStateIO::new().with_prerelease_state({
1122                let mut state = PrereleaseState::default();
1123                state.insert("crate-a".to_string(), "alpha".to_string());
1124                state
1125            }));
1126            let interaction = Arc::new(
1127                MockManageInteractionProvider::new().with_prerelease_actions(vec![
1128                    MenuSelection::Selected(PrereleaseAction::Add),
1129                    MenuSelection::Selected(PrereleaseAction::Done),
1130                ]),
1131            );
1132
1133            let operation = PrereleaseManageOperation::new(
1134                project_provider,
1135                Arc::clone(&release_state_io),
1136                Arc::clone(&interaction),
1137            );
1138
1139            let events = operation
1140                .execute(std::path::Path::new("/any"))
1141                .expect("prerelease manage operation should execute without error");
1142
1143            assert!(events.contains(&PrereleaseEvent::AllPackagesInPrerelease));
1144        }
1145    }
1146
1147    mod graduation_operation {
1148        use super::*;
1149        use crate::mocks::{
1150            MockManageInteractionProvider, MockProjectProvider, MockReleaseStateIO,
1151        };
1152        use std::sync::Arc;
1153
1154        #[test]
1155        fn exits_on_done_action() {
1156            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "0.1.0")]);
1157            let release_state_io = Arc::new(MockReleaseStateIO::new());
1158            let interaction = Arc::new(
1159                MockManageInteractionProvider::new()
1160                    .with_graduation_actions(vec![MenuSelection::Selected(GraduationAction::Done)]),
1161            );
1162
1163            let operation = GraduationManageOperation::new(
1164                project_provider,
1165                Arc::clone(&release_state_io),
1166                Arc::clone(&interaction),
1167            );
1168
1169            let events = operation
1170                .execute(std::path::Path::new("/any"))
1171                .expect("graduation manage operation should execute without error");
1172
1173            assert_eq!(events.len(), 1);
1174            assert!(
1175                events
1176                    .iter()
1177                    .any(|e| matches!(e, GraduationEvent::DisplayState(_)))
1178            );
1179        }
1180
1181        #[test]
1182        fn exits_on_cancelled() {
1183            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "0.1.0")]);
1184            let release_state_io = Arc::new(MockReleaseStateIO::new());
1185            let interaction = Arc::new(
1186                MockManageInteractionProvider::new()
1187                    .with_graduation_actions(vec![MenuSelection::Cancelled]),
1188            );
1189
1190            let operation = GraduationManageOperation::new(
1191                project_provider,
1192                Arc::clone(&release_state_io),
1193                Arc::clone(&interaction),
1194            );
1195
1196            let events = operation
1197                .execute(std::path::Path::new("/any"))
1198                .expect("graduation manage operation should execute without error");
1199
1200            assert_eq!(events.len(), 1);
1201            assert!(
1202                events
1203                    .iter()
1204                    .any(|e| matches!(e, GraduationEvent::DisplayState(_)))
1205            );
1206        }
1207
1208        #[test]
1209        fn cancels_add_when_package_selection_cancelled() {
1210            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "0.1.0")]);
1211            let release_state_io = Arc::new(MockReleaseStateIO::new());
1212            let interaction = Arc::new(
1213                MockManageInteractionProvider::new()
1214                    .with_graduation_actions(vec![
1215                        MenuSelection::Selected(GraduationAction::Add),
1216                        MenuSelection::Selected(GraduationAction::Done),
1217                    ])
1218                    .with_graduation_selections(vec![MenuSelection::Cancelled]),
1219            );
1220
1221            let operation = GraduationManageOperation::new(
1222                project_provider,
1223                Arc::clone(&release_state_io),
1224                Arc::clone(&interaction),
1225            );
1226
1227            let events = operation
1228                .execute(std::path::Path::new("/any"))
1229                .expect("graduation manage operation should execute without error");
1230
1231            assert!(
1232                !events
1233                    .iter()
1234                    .any(|e| matches!(e, GraduationEvent::Added { .. }))
1235            );
1236            assert!(release_state_io.get_graduation_state().is_none());
1237        }
1238
1239        #[test]
1240        fn reports_no_eligible_for_graduation_when_all_stable() {
1241            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "1.0.0")]);
1242            let release_state_io = Arc::new(MockReleaseStateIO::new());
1243            let interaction = Arc::new(
1244                MockManageInteractionProvider::new().with_graduation_actions(vec![
1245                    MenuSelection::Selected(GraduationAction::Add),
1246                    MenuSelection::Selected(GraduationAction::Done),
1247                ]),
1248            );
1249
1250            let operation = GraduationManageOperation::new(
1251                project_provider,
1252                Arc::clone(&release_state_io),
1253                Arc::clone(&interaction),
1254            );
1255
1256            let events = operation
1257                .execute(std::path::Path::new("/any"))
1258                .expect("graduation manage operation should execute without error");
1259
1260            assert!(events.contains(&GraduationEvent::NoEligibleForGraduation));
1261        }
1262
1263        #[test]
1264        fn cancels_remove_when_selection_cancelled() {
1265            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "0.1.0")]);
1266            let release_state_io = Arc::new(MockReleaseStateIO::new().with_graduation_state({
1267                let mut state = GraduationState::default();
1268                state.add("crate-a".to_string());
1269                state
1270            }));
1271            let interaction = Arc::new(
1272                MockManageInteractionProvider::new()
1273                    .with_graduation_actions(vec![
1274                        MenuSelection::Selected(GraduationAction::Remove),
1275                        MenuSelection::Selected(GraduationAction::Done),
1276                    ])
1277                    .with_remove_graduation_selections(vec![MenuSelection::Cancelled]),
1278            );
1279
1280            let operation = GraduationManageOperation::new(
1281                project_provider,
1282                Arc::clone(&release_state_io),
1283                Arc::clone(&interaction),
1284            );
1285
1286            let events = operation
1287                .execute(std::path::Path::new("/any"))
1288                .expect("graduation manage operation should execute without error");
1289
1290            assert!(
1291                !events
1292                    .iter()
1293                    .any(|e| matches!(e, GraduationEvent::Removed { .. }))
1294            );
1295            let graduation_state = release_state_io
1296                .get_graduation_state()
1297                .expect("graduation state should still exist");
1298            assert!(graduation_state.contains("crate-a"));
1299        }
1300
1301        #[test]
1302        fn adds_package_to_graduation() {
1303            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "0.1.0")]);
1304            let release_state_io = Arc::new(MockReleaseStateIO::new());
1305            let interaction = Arc::new(
1306                MockManageInteractionProvider::new()
1307                    .with_graduation_actions(vec![
1308                        MenuSelection::Selected(GraduationAction::Add),
1309                        MenuSelection::Selected(GraduationAction::Done),
1310                    ])
1311                    .with_graduation_selections(vec![MenuSelection::Selected(0)]),
1312            );
1313
1314            let operation = GraduationManageOperation::new(
1315                project_provider,
1316                Arc::clone(&release_state_io),
1317                Arc::clone(&interaction),
1318            );
1319
1320            let events = operation
1321                .execute(std::path::Path::new("/any"))
1322                .expect("graduation manage operation should execute without error");
1323
1324            assert!(events.contains(&GraduationEvent::Added {
1325                crate_name: "crate-a".to_string(),
1326            }));
1327
1328            let graduation_state = release_state_io
1329                .get_graduation_state()
1330                .expect("graduation state should be saved");
1331            assert!(graduation_state.contains("crate-a"));
1332        }
1333
1334        #[test]
1335        fn removes_package_from_graduation() {
1336            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "0.1.0")]);
1337            let release_state_io = Arc::new(MockReleaseStateIO::new().with_graduation_state({
1338                let mut state = GraduationState::default();
1339                state.add("crate-a".to_string());
1340                state
1341            }));
1342            let interaction = Arc::new(
1343                MockManageInteractionProvider::new()
1344                    .with_graduation_actions(vec![
1345                        MenuSelection::Selected(GraduationAction::Remove),
1346                        MenuSelection::Selected(GraduationAction::Done),
1347                    ])
1348                    .with_remove_graduation_selections(vec![MenuSelection::Selected(0)]),
1349            );
1350
1351            let operation = GraduationManageOperation::new(
1352                project_provider,
1353                Arc::clone(&release_state_io),
1354                Arc::clone(&interaction),
1355            );
1356
1357            let events = operation
1358                .execute(std::path::Path::new("/any"))
1359                .expect("graduation manage operation should execute without error");
1360
1361            assert!(events.contains(&GraduationEvent::Removed {
1362                crate_name: "crate-a".to_string(),
1363            }));
1364            match release_state_io.get_graduation_state() {
1365                None => {}
1366                Some(state) => assert!(!state.contains("crate-a")),
1367            }
1368        }
1369
1370        #[test]
1371        fn reports_no_graduation_packages_on_remove() {
1372            let project_provider = MockProjectProvider::workspace(vec![("crate-a", "0.1.0")]);
1373            let release_state_io = Arc::new(MockReleaseStateIO::new());
1374            let interaction = Arc::new(
1375                MockManageInteractionProvider::new().with_graduation_actions(vec![
1376                    MenuSelection::Selected(GraduationAction::Remove),
1377                    MenuSelection::Selected(GraduationAction::Done),
1378                ]),
1379            );
1380
1381            let operation = GraduationManageOperation::new(
1382                project_provider,
1383                Arc::clone(&release_state_io),
1384                Arc::clone(&interaction),
1385            );
1386
1387            let events = operation
1388                .execute(std::path::Path::new("/any"))
1389                .expect("graduation manage operation should execute without error");
1390
1391            assert!(events.contains(&GraduationEvent::NoGraduationPackages));
1392        }
1393    }
1394}