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