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 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}