Skip to main content

changeset_operations/operations/
add.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use changeset_core::{BumpType, ChangeCategory, Changeset, PackageInfo, PackageRelease};
5use indexmap::IndexSet;
6
7use crate::Result;
8use crate::error::OperationError;
9use crate::traits::{
10    BumpSelection, CategorySelection, ChangesetWriter, DependencyGraphProvider, DescriptionInput,
11    InteractionProvider, PackageSelection, ProjectProvider,
12};
13
14pub struct AddInput {
15    pub packages: Vec<String>,
16    pub bump: Option<BumpType>,
17    pub package_bumps: HashMap<String, BumpType>,
18    pub category: ChangeCategory,
19    pub description: Option<String>,
20    pub exclude_dependents: bool,
21}
22
23impl Default for AddInput {
24    fn default() -> Self {
25        Self {
26            packages: Vec::new(),
27            bump: None,
28            package_bumps: HashMap::new(),
29            category: ChangeCategory::Changed,
30            description: None,
31            exclude_dependents: false,
32        }
33    }
34}
35
36#[derive(Debug)]
37pub enum AddResult {
38    Created {
39        changeset: Changeset,
40        file_path: PathBuf,
41        uncovered_dependents: Vec<String>,
42    },
43    Cancelled,
44    NoPackages,
45}
46
47pub struct AddOperation<P, W, I> {
48    project_provider: P,
49    changeset_writer: W,
50    interaction_provider: I,
51}
52
53impl<P, W, I> AddOperation<P, W, I>
54where
55    P: ProjectProvider + DependencyGraphProvider,
56    W: ChangesetWriter,
57    I: InteractionProvider,
58{
59    pub fn new(project_provider: P, changeset_writer: W, interaction_provider: I) -> Self {
60        Self {
61            project_provider,
62            changeset_writer,
63            interaction_provider,
64        }
65    }
66
67    /// # Errors
68    ///
69    /// Returns an error if the project cannot be discovered, has no packages, or
70    /// if the changeset cannot be written.
71    pub fn execute(&self, start_path: &Path, input: AddInput) -> Result<AddResult> {
72        let project = self.project_provider.discover_project(start_path)?;
73
74        if project.packages().is_empty() {
75            return Err(OperationError::EmptyProject(project.root().to_path_buf()));
76        }
77
78        let graph = if !input.exclude_dependents && project.packages().len() > 1 {
79            Some(self.project_provider.build_dependency_graph(&project)?)
80        } else {
81            None
82        };
83
84        let display_labels: Option<Vec<String>> = graph.as_ref().map(|g| {
85            project
86                .packages()
87                .iter()
88                .map(|pkg| {
89                    let dependents = g.transitive_dependents(pkg.name());
90                    if dependents.is_empty() {
91                        format!("{} ({})", pkg.name(), pkg.version())
92                    } else {
93                        let mut dep_list: Vec<&str> = dependents.into_iter().collect();
94                        dep_list.sort_unstable();
95                        format!(
96                            "{} ({}) [depended on by: {}]",
97                            pkg.name(),
98                            pkg.version(),
99                            dep_list.join(", ")
100                        )
101                    }
102                })
103                .collect()
104        });
105
106        let packages =
107            match self.select_packages(project.packages(), &input, display_labels.as_deref())? {
108                Some(packages) if packages.is_empty() => return Ok(AddResult::NoPackages),
109                Some(packages) => packages,
110                None => return Ok(AddResult::Cancelled),
111            };
112
113        let uncovered_dependents = if let Some(ref g) = graph {
114            let selected_names: Vec<&str> = packages.iter().map(|p| p.name().as_str()).collect();
115            let dependents = g.transitive_dependents_of_set(&selected_names);
116            let mut result: Vec<String> = dependents.into_iter().map(String::from).collect();
117            result.sort();
118            result
119        } else {
120            Vec::new()
121        };
122
123        let Some(releases) = self.collect_releases(&packages, &input)? else {
124            return Ok(AddResult::Cancelled);
125        };
126
127        let Some(category) = self.select_category(&input)? else {
128            return Ok(AddResult::Cancelled);
129        };
130
131        let Some(desc) = self.get_description(&input)? else {
132            return Ok(AddResult::Cancelled);
133        };
134
135        let description = desc.trim().to_string();
136        if description.is_empty() {
137            return Err(OperationError::EmptyDescription);
138        }
139
140        let changeset = Changeset::new(description, releases, category);
141
142        let (root_config, _) = self.project_provider.load_configs(&project)?;
143        let changeset_dir = self
144            .project_provider
145            .ensure_changeset_dir(&project, &root_config)?;
146
147        let filename = self
148            .changeset_writer
149            .write_changeset(&changeset_dir, &changeset)?;
150        let file_path = changeset_dir.join(&filename);
151
152        Ok(AddResult::Created {
153            changeset,
154            file_path,
155            uncovered_dependents,
156        })
157    }
158
159    fn select_packages(
160        &self,
161        available: &[PackageInfo],
162        input: &AddInput,
163        display_labels: Option<&[String]>,
164    ) -> Result<Option<Vec<PackageInfo>>> {
165        let explicit_packages = collect_explicit_packages(input);
166
167        if !explicit_packages.is_empty() {
168            let packages = resolve_explicit_packages(available, &explicit_packages)?;
169            return Ok(Some(packages));
170        }
171
172        if available.len() == 1 {
173            return Ok(Some(vec![available[0].clone()]));
174        }
175
176        match self
177            .interaction_provider
178            .select_packages(available, display_labels)?
179        {
180            PackageSelection::Selected(packages) => Ok(Some(packages)),
181            PackageSelection::Cancelled => Ok(None),
182        }
183    }
184
185    fn collect_releases(
186        &self,
187        packages: &[PackageInfo],
188        input: &AddInput,
189    ) -> Result<Option<Vec<PackageRelease>>> {
190        let mut releases = Vec::with_capacity(packages.len());
191
192        for package in packages {
193            let bump_type = if let Some(bump) = input.package_bumps.get(package.name()) {
194                *bump
195            } else if let Some(bump) = input.bump {
196                bump
197            } else {
198                match self.interaction_provider.select_bump_type(package.name())? {
199                    BumpSelection::Selected(bump) => bump,
200                    BumpSelection::Cancelled => return Ok(None),
201                }
202            };
203
204            releases.push(PackageRelease::new(package.name().clone(), bump_type));
205        }
206
207        Ok(Some(releases))
208    }
209
210    fn select_category(&self, input: &AddInput) -> Result<Option<ChangeCategory>> {
211        let has_explicit_input = input.description.is_some()
212            || input.bump.is_some()
213            || !input.packages.is_empty()
214            || !input.package_bumps.is_empty();
215
216        if input.category != ChangeCategory::default() || has_explicit_input {
217            return Ok(Some(input.category));
218        }
219
220        match self.interaction_provider.select_category()? {
221            CategorySelection::Selected(category) => Ok(Some(category)),
222            CategorySelection::Cancelled => Ok(None),
223        }
224    }
225
226    fn get_description(&self, input: &AddInput) -> Result<Option<String>> {
227        if let Some(description) = &input.description {
228            return Ok(Some(description.clone()));
229        }
230
231        match self.interaction_provider.get_description()? {
232            DescriptionInput::Provided(description) => Ok(Some(description)),
233            DescriptionInput::Cancelled => Ok(None),
234        }
235    }
236}
237
238fn collect_explicit_packages(input: &AddInput) -> Vec<String> {
239    let mut packages: IndexSet<String> = input.packages.iter().cloned().collect();
240
241    for name in input.package_bumps.keys() {
242        packages.insert(name.clone());
243    }
244
245    packages.into_iter().collect()
246}
247
248fn resolve_explicit_packages(
249    packages: &[PackageInfo],
250    package_names: &[String],
251) -> Result<Vec<PackageInfo>> {
252    let unique_names: IndexSet<&String> = package_names.iter().collect();
253    let mut selected = Vec::with_capacity(unique_names.len());
254
255    for name in unique_names {
256        let package = packages.iter().find(|p| p.name() == name).ok_or_else(|| {
257            let available = packages
258                .iter()
259                .map(|p| p.name().as_str())
260                .collect::<Vec<_>>()
261                .join(", ");
262            OperationError::UnknownPackage {
263                name: name.clone(),
264                available,
265            }
266        })?;
267        selected.push(package.clone());
268    }
269
270    Ok(selected)
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::mocks::{
277        MockChangesetWriter, MockInteractionProvider, MockProjectProvider, make_package,
278    };
279
280    #[test]
281    fn collect_explicit_packages_from_packages_list() {
282        let input = AddInput {
283            packages: vec!["a".to_string(), "b".to_string()],
284            ..Default::default()
285        };
286
287        let packages = collect_explicit_packages(&input);
288
289        assert_eq!(packages.len(), 2);
290        assert!(packages.contains(&"a".to_string()));
291        assert!(packages.contains(&"b".to_string()));
292    }
293
294    #[test]
295    fn collect_explicit_packages_from_package_bumps() {
296        let mut package_bumps = HashMap::new();
297        package_bumps.insert("a".to_string(), BumpType::Major);
298        package_bumps.insert("b".to_string(), BumpType::Minor);
299
300        let input = AddInput {
301            package_bumps,
302            ..Default::default()
303        };
304
305        let packages = collect_explicit_packages(&input);
306
307        assert_eq!(packages.len(), 2);
308        assert!(packages.contains(&"a".to_string()));
309        assert!(packages.contains(&"b".to_string()));
310    }
311
312    #[test]
313    fn collect_explicit_packages_merges_and_deduplicates() {
314        let mut package_bumps = HashMap::new();
315        package_bumps.insert("a".to_string(), BumpType::Major);
316        package_bumps.insert("b".to_string(), BumpType::Minor);
317
318        let input = AddInput {
319            packages: vec!["a".to_string(), "c".to_string()],
320            package_bumps,
321            ..Default::default()
322        };
323
324        let packages = collect_explicit_packages(&input);
325
326        assert_eq!(packages.len(), 3);
327        assert!(packages.contains(&"a".to_string()));
328        assert!(packages.contains(&"b".to_string()));
329        assert!(packages.contains(&"c".to_string()));
330    }
331
332    #[test]
333    fn collect_explicit_packages_empty() {
334        let input = AddInput::default();
335
336        let packages = collect_explicit_packages(&input);
337
338        assert!(packages.is_empty());
339    }
340
341    #[test]
342    fn creates_changeset_for_single_package_project() {
343        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
344        let writer = MockChangesetWriter::new().with_filename("test-changeset.md");
345        let interaction = MockInteractionProvider::all_cancelled();
346
347        let operation = AddOperation::new(project_provider, writer, interaction);
348
349        let input = AddInput {
350            packages: vec!["my-crate".to_string()],
351            bump: Some(BumpType::Patch),
352            description: Some("Fix a bug".to_string()),
353            ..Default::default()
354        };
355
356        let result = operation
357            .execute(Path::new("/any"), input)
358            .expect("AddOperation failed with valid single-package input");
359
360        match result {
361            AddResult::Created {
362                changeset,
363                file_path,
364                ..
365            } => {
366                assert_eq!(changeset.summary(), "Fix a bug");
367                assert_eq!(changeset.releases().len(), 1);
368                assert_eq!(changeset.releases()[0].name(), "my-crate");
369                assert_eq!(changeset.releases()[0].bump_type(), BumpType::Patch);
370                assert!(file_path.ends_with("test-changeset.md"));
371            }
372            _ => panic!("Expected AddResult::Created"),
373        }
374    }
375
376    #[test]
377    fn creates_changeset_with_multiple_packages() {
378        let project_provider =
379            MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.0.0")]);
380        let writer = MockChangesetWriter::new();
381        let interaction = MockInteractionProvider::all_cancelled();
382
383        let operation = AddOperation::new(project_provider, writer, interaction);
384
385        let mut package_bumps = HashMap::new();
386        package_bumps.insert("crate-a".to_string(), BumpType::Major);
387        package_bumps.insert("crate-b".to_string(), BumpType::Minor);
388
389        let input = AddInput {
390            package_bumps,
391            description: Some("Breaking change".to_string()),
392            ..Default::default()
393        };
394
395        let result = operation
396            .execute(Path::new("/any"), input)
397            .expect("AddOperation failed with valid multi-package input");
398
399        match result {
400            AddResult::Created { changeset, .. } => {
401                assert_eq!(changeset.releases().len(), 2);
402                let names: Vec<_> = changeset
403                    .releases()
404                    .iter()
405                    .map(|r| r.name().as_str())
406                    .collect();
407                assert!(names.contains(&"crate-a"));
408                assert!(names.contains(&"crate-b"));
409            }
410            _ => panic!("Expected AddResult::Created"),
411        }
412    }
413
414    #[test]
415    fn returns_cancelled_when_package_selection_cancelled() {
416        let project_provider = MockProjectProvider::workspace(vec![("a", "1.0.0"), ("b", "1.0.0")]);
417        let writer = MockChangesetWriter::new();
418        let interaction = MockInteractionProvider::all_cancelled();
419
420        let operation = AddOperation::new(project_provider, writer, interaction);
421
422        let result = operation
423            .execute(Path::new("/any"), AddInput::default())
424            .expect("AddOperation should not fail when interaction is cancelled");
425
426        assert!(matches!(result, AddResult::Cancelled));
427    }
428
429    #[test]
430    fn returns_cancelled_when_bump_selection_cancelled() {
431        let packages = vec![make_package("my-crate", "1.0.0")];
432        let project_provider = MockProjectProvider::workspace(vec![("my-crate", "1.0.0")]);
433        let writer = MockChangesetWriter::new();
434        let interaction = MockInteractionProvider {
435            package_selection: crate::traits::PackageSelection::Selected(packages),
436            bump_selections: std::sync::Mutex::new(vec![]),
437            category_selection: crate::traits::CategorySelection::Selected(ChangeCategory::Changed),
438            description: crate::traits::DescriptionInput::Provided("test".to_string()),
439        };
440
441        let operation = AddOperation::new(project_provider, writer, interaction);
442
443        let result = operation
444            .execute(Path::new("/any"), AddInput::default())
445            .expect("AddOperation should not fail when bump selection is cancelled");
446
447        assert!(matches!(result, AddResult::Cancelled));
448    }
449
450    #[test]
451    fn returns_error_for_unknown_package() {
452        let project_provider = MockProjectProvider::single_package("real-crate", "1.0.0");
453        let writer = MockChangesetWriter::new();
454        let interaction = MockInteractionProvider::all_cancelled();
455
456        let operation = AddOperation::new(project_provider, writer, interaction);
457
458        let input = AddInput {
459            packages: vec!["unknown-crate".to_string()],
460            bump: Some(BumpType::Patch),
461            description: Some("Test".to_string()),
462            ..Default::default()
463        };
464
465        let result = operation.execute(Path::new("/any"), input);
466
467        assert!(result.is_err());
468        let err = result.expect_err("AddOperation should fail for unknown package");
469        assert!(matches!(err, crate::OperationError::UnknownPackage { .. }));
470    }
471
472    #[test]
473    fn returns_error_for_empty_description() {
474        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
475        let writer = MockChangesetWriter::new();
476        let interaction = MockInteractionProvider::all_cancelled();
477
478        let operation = AddOperation::new(project_provider, writer, interaction);
479
480        let input = AddInput {
481            packages: vec!["my-crate".to_string()],
482            bump: Some(BumpType::Patch),
483            description: Some("   ".to_string()),
484            ..Default::default()
485        };
486
487        let result = operation.execute(Path::new("/any"), input);
488
489        assert!(result.is_err());
490        let err = result.expect_err("AddOperation should fail for empty description");
491        assert!(matches!(err, crate::OperationError::EmptyDescription));
492    }
493
494    #[test]
495    fn uses_interactive_selection_for_workspace_without_explicit_packages() {
496        let packages = vec![
497            make_package("crate-a", "1.0.0"),
498            make_package("crate-b", "2.0.0"),
499        ];
500        let project_provider =
501            MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.0.0")]);
502        let writer = MockChangesetWriter::new();
503        let interaction =
504            MockInteractionProvider::with_selections(packages, BumpType::Minor, "Interactive desc")
505                .with_bump_sequence(vec![BumpType::Minor, BumpType::Minor]);
506
507        let operation = AddOperation::new(project_provider, writer, interaction);
508
509        let result = operation
510            .execute(Path::new("/any"), AddInput::default())
511            .expect("AddOperation failed with interactive workspace selection");
512
513        match result {
514            AddResult::Created { changeset, .. } => {
515                assert_eq!(changeset.summary(), "Interactive desc");
516                assert_eq!(changeset.releases().len(), 2);
517            }
518            _ => panic!("Expected AddResult::Created"),
519        }
520    }
521
522    #[test]
523    fn auto_selects_single_package_without_interaction() {
524        let project_provider = MockProjectProvider::single_package("solo-crate", "1.0.0");
525        let writer = MockChangesetWriter::new();
526        let interaction = MockInteractionProvider::with_selections(
527            vec![make_package("solo-crate", "1.0.0")],
528            BumpType::Patch,
529            "Auto-selected",
530        );
531
532        let operation = AddOperation::new(project_provider, writer, interaction);
533
534        let input = AddInput {
535            bump: Some(BumpType::Patch),
536            description: Some("Non-interactive description".to_string()),
537            ..Default::default()
538        };
539
540        let result = operation
541            .execute(Path::new("/any"), input)
542            .expect("AddOperation failed for single-package auto-selection");
543
544        match result {
545            AddResult::Created { changeset, .. } => {
546                assert_eq!(changeset.releases().len(), 1);
547                assert_eq!(changeset.releases()[0].name(), "solo-crate");
548                assert_eq!(changeset.summary(), "Non-interactive description");
549            }
550            _ => panic!("Expected AddResult::Created"),
551        }
552    }
553
554    #[test]
555    fn respects_explicit_category() {
556        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
557        let writer = MockChangesetWriter::new();
558        let interaction = MockInteractionProvider::all_cancelled();
559
560        let operation = AddOperation::new(project_provider, writer, interaction);
561
562        let input = AddInput {
563            packages: vec!["my-crate".to_string()],
564            bump: Some(BumpType::Minor),
565            category: ChangeCategory::Fixed,
566            description: Some("Bug fix".to_string()),
567            ..Default::default()
568        };
569
570        let result = operation
571            .execute(Path::new("/any"), input)
572            .expect("AddOperation failed with explicit category");
573
574        match result {
575            AddResult::Created { changeset, .. } => {
576                assert_eq!(changeset.category(), ChangeCategory::Fixed);
577            }
578            _ => panic!("Expected AddResult::Created"),
579        }
580    }
581
582    #[test]
583    fn creates_changeset_file_in_project() {
584        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
585        let writer = MockChangesetWriter::new().with_filename("my-changeset.md");
586        let interaction = MockInteractionProvider::all_cancelled();
587
588        let operation = AddOperation::new(project_provider, writer, interaction);
589
590        let input = AddInput {
591            packages: vec!["my-crate".to_string()],
592            bump: Some(BumpType::Patch),
593            description: Some("Test description".to_string()),
594            ..Default::default()
595        };
596
597        let result = operation
598            .execute(Path::new("/any"), input)
599            .expect("AddOperation failed to create changeset file");
600
601        match result {
602            AddResult::Created { file_path, .. } => {
603                assert!(file_path.to_string_lossy().contains("my-changeset.md"));
604            }
605            _ => panic!("Expected AddResult::Created"),
606        }
607    }
608
609    #[test]
610    fn none_bump_without_description_returns_empty_description_error() {
611        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
612        let writer = MockChangesetWriter::new();
613        let interaction = MockInteractionProvider::all_cancelled();
614
615        let operation = AddOperation::new(project_provider, writer, interaction);
616
617        let input = AddInput {
618            packages: vec!["my-crate".to_string()],
619            bump: Some(BumpType::None),
620            description: Some("   ".to_string()),
621            ..Default::default()
622        };
623
624        let result = operation.execute(Path::new("/any"), input);
625
626        assert!(result.is_err());
627        let err = result.expect_err("AddOperation should fail for none bump without description");
628        assert!(matches!(err, crate::OperationError::EmptyDescription));
629    }
630
631    #[test]
632    fn none_bump_with_explicit_description_creates_changeset() {
633        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
634        let writer = MockChangesetWriter::new();
635        let interaction = MockInteractionProvider::all_cancelled();
636
637        let operation = AddOperation::new(project_provider, writer, interaction);
638
639        let input = AddInput {
640            packages: vec!["my-crate".to_string()],
641            bump: Some(BumpType::None),
642            description: Some("Internal refactoring".to_string()),
643            ..Default::default()
644        };
645
646        let result = operation
647            .execute(Path::new("/any"), input)
648            .expect("AddOperation should succeed for none bump with description");
649
650        match result {
651            AddResult::Created { changeset, .. } => {
652                assert_eq!(changeset.summary(), "Internal refactoring");
653                assert_eq!(changeset.releases()[0].bump_type(), BumpType::None);
654            }
655            _ => panic!("Expected AddResult::Created"),
656        }
657    }
658
659    #[test]
660    fn none_bump_interactive_description_creates_changeset() {
661        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
662        let writer = MockChangesetWriter::new();
663        let interaction = MockInteractionProvider {
664            package_selection: crate::traits::PackageSelection::Cancelled,
665            bump_selections: std::sync::Mutex::new(vec![]),
666            category_selection: crate::traits::CategorySelection::Selected(ChangeCategory::Changed),
667            description: crate::traits::DescriptionInput::Provided(
668                "Interactive reason".to_string(),
669            ),
670        };
671
672        let operation = AddOperation::new(project_provider, writer, interaction);
673
674        let input = AddInput {
675            bump: Some(BumpType::None),
676            ..Default::default()
677        };
678
679        let result = operation
680            .execute(Path::new("/any"), input)
681            .expect("AddOperation should succeed with interactive description for none bump");
682
683        match result {
684            AddResult::Created { changeset, .. } => {
685                assert_eq!(changeset.summary(), "Interactive reason");
686                assert_eq!(changeset.releases()[0].bump_type(), BumpType::None);
687                assert_eq!(changeset.category(), ChangeCategory::Changed);
688            }
689            _ => panic!("Expected AddResult::Created"),
690        }
691    }
692
693    #[test]
694    fn mixed_none_and_patch_bumps_creates_changeset() {
695        let project_provider =
696            MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.0.0")]);
697        let writer = MockChangesetWriter::new();
698        let interaction = MockInteractionProvider::all_cancelled();
699
700        let operation = AddOperation::new(project_provider, writer, interaction);
701
702        let mut package_bumps = HashMap::new();
703        package_bumps.insert("crate-a".to_string(), BumpType::None);
704        package_bumps.insert("crate-b".to_string(), BumpType::Patch);
705
706        let input = AddInput {
707            package_bumps,
708            description: Some("Update crate-b with internal crate-a changes".to_string()),
709            ..Default::default()
710        };
711
712        let result = operation
713            .execute(Path::new("/any"), input)
714            .expect("AddOperation should succeed for mixed none/patch bumps with description");
715
716        match result {
717            AddResult::Created { changeset, .. } => {
718                assert_eq!(
719                    changeset.summary(),
720                    "Update crate-b with internal crate-a changes"
721                );
722                assert_eq!(changeset.releases().len(), 2);
723                let none_release = changeset
724                    .releases()
725                    .iter()
726                    .find(|r| r.name() == "crate-a")
727                    .expect("crate-a should be in releases");
728                assert_eq!(none_release.bump_type(), BumpType::None);
729                let patch_release = changeset
730                    .releases()
731                    .iter()
732                    .find(|r| r.name() == "crate-b")
733                    .expect("crate-b should be in releases");
734                assert_eq!(patch_release.bump_type(), BumpType::Patch);
735            }
736            _ => panic!("Expected AddResult::Created"),
737        }
738    }
739
740    #[test]
741    fn exclude_dependents_skips_dependency_computation() {
742        let project_provider =
743            MockProjectProvider::workspace(vec![("core", "1.0.0"), ("app", "1.0.0")])
744                .with_dependency_edges(vec![("app", "core")]);
745        let writer = MockChangesetWriter::new();
746        let interaction = MockInteractionProvider::all_cancelled();
747
748        let operation = AddOperation::new(project_provider, writer, interaction);
749
750        let input = AddInput {
751            packages: vec!["core".to_string()],
752            bump: Some(BumpType::Patch),
753            description: Some("Fix in core".to_string()),
754            exclude_dependents: true,
755            ..Default::default()
756        };
757
758        let result = operation
759            .execute(Path::new("/any"), input)
760            .expect("AddOperation should succeed with exclude_dependents");
761
762        match result {
763            AddResult::Created {
764                uncovered_dependents,
765                ..
766            } => {
767                assert!(uncovered_dependents.is_empty());
768            }
769            _ => panic!("Expected AddResult::Created"),
770        }
771    }
772
773    #[test]
774    fn non_interactive_with_dependents_returns_uncovered() {
775        let project_provider = MockProjectProvider::workspace(vec![
776            ("core", "1.0.0"),
777            ("lib", "1.0.0"),
778            ("app", "1.0.0"),
779        ])
780        .with_dependency_edges(vec![("lib", "core"), ("app", "lib")]);
781        let writer = MockChangesetWriter::new();
782        let interaction = MockInteractionProvider::all_cancelled();
783
784        let operation = AddOperation::new(project_provider, writer, interaction);
785
786        let input = AddInput {
787            packages: vec!["core".to_string()],
788            bump: Some(BumpType::Patch),
789            description: Some("Fix in core".to_string()),
790            ..Default::default()
791        };
792
793        let result = operation
794            .execute(Path::new("/any"), input)
795            .expect("AddOperation should succeed and report uncovered dependents");
796
797        match result {
798            AddResult::Created {
799                uncovered_dependents,
800                ..
801            } => {
802                assert!(uncovered_dependents.contains(&"lib".to_string()));
803                assert!(uncovered_dependents.contains(&"app".to_string()));
804                assert_eq!(uncovered_dependents.len(), 2);
805            }
806            _ => panic!("Expected AddResult::Created"),
807        }
808    }
809
810    #[test]
811    fn single_package_workspace_skips_dependency_computation() {
812        let project_provider = MockProjectProvider::single_package("solo", "1.0.0");
813        let writer = MockChangesetWriter::new();
814        let interaction = MockInteractionProvider::all_cancelled();
815
816        let operation = AddOperation::new(project_provider, writer, interaction);
817
818        let input = AddInput {
819            packages: vec!["solo".to_string()],
820            bump: Some(BumpType::Minor),
821            description: Some("New feature".to_string()),
822            ..Default::default()
823        };
824
825        let result = operation
826            .execute(Path::new("/any"), input)
827            .expect("AddOperation should succeed for single-package project");
828
829        match result {
830            AddResult::Created {
831                uncovered_dependents,
832                ..
833            } => {
834                assert!(uncovered_dependents.is_empty());
835            }
836            _ => panic!("Expected AddResult::Created"),
837        }
838    }
839}