1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use changeset_core::{BumpType, Changeset, PackageInfo};
5use changeset_project::WorkspaceDependencyGraph;
6use changeset_version::max_bump_type;
7use derive_builder::Builder;
8use gset::Getset;
9use indexmap::IndexMap;
10
11use crate::Result;
12use crate::planner::VersionPlanner;
13use crate::traits::{
14 ChangesetReader, DependencyGraphProvider, InheritedVersionChecker, ProjectProvider,
15};
16use crate::types::PackageVersion;
17
18#[derive(Builder, Getset, Default)]
19#[builder(default)]
20pub struct StatusOutput {
21 #[getset(get, vis = "pub")]
22 pub(crate) changesets: Vec<Changeset>,
23 #[getset(get, vis = "pub")]
24 pub(crate) changeset_files: Vec<PathBuf>,
25 #[getset(get, vis = "pub")]
26 pub(crate) projected_releases: Vec<PackageVersion>,
27 #[getset(get, vis = "pub")]
28 pub(crate) bumps_by_package: IndexMap<String, Vec<BumpType>>,
29 #[getset(get, vis = "pub")]
30 pub(crate) none_bump_packages: Vec<String>,
31 #[getset(get, vis = "pub")]
32 pub(crate) unchanged_packages: Vec<PackageInfo>,
33 #[getset(get, vis = "pub")]
34 pub(crate) packages_with_inherited_versions: Vec<String>,
35 #[getset(get, vis = "pub")]
36 pub(crate) unknown_packages: Vec<String>,
37 #[getset(get, vis = "pub")]
38 pub(crate) consumed_prerelease_changesets: Vec<(PathBuf, String)>,
39 #[getset(get, vis = "pub")]
40 pub(crate) uncovered_dependents: Vec<(String, Vec<String>)>,
41}
42
43pub struct StatusOperation<P, R, I> {
44 project_provider: P,
45 changeset_reader: R,
46 inherited_checker: I,
47}
48
49impl<P, R, I> StatusOperation<P, R, I>
50where
51 P: ProjectProvider + DependencyGraphProvider,
52 R: ChangesetReader,
53 I: InheritedVersionChecker,
54{
55 pub fn new(project_provider: P, changeset_reader: R, inherited_checker: I) -> Self {
56 Self {
57 project_provider,
58 changeset_reader,
59 inherited_checker,
60 }
61 }
62
63 pub fn execute(&self, start_path: &Path) -> Result<StatusOutput> {
68 let project = self.project_provider.discover_project(start_path)?;
69 let (root_config, _) = self.project_provider.load_configs(&project)?;
70
71 let changeset_dir = project.root().join(root_config.changeset_dir());
72 let changeset_files = self.changeset_reader.list_changesets(&changeset_dir)?;
73
74 let mut changesets = Vec::new();
75 for path in &changeset_files {
76 let changeset = self.changeset_reader.read_changeset(path)?;
77 changesets.push(changeset);
78 }
79
80 let changesets = crate::none_bump::apply_none_bump_behavior(
81 changesets,
82 root_config.none_bump_behavior(),
83 root_config.none_bump_promote_message_template(),
84 )?;
85
86 let consumed_changeset_paths = self
87 .changeset_reader
88 .list_consumed_changesets(&changeset_dir)?;
89 let consumed_prerelease_changesets =
90 Self::collect_consumed_changesets(&self.changeset_reader, &consumed_changeset_paths)?;
91
92 let bumps_by_package = VersionPlanner::aggregate_bumps(&changesets);
93
94 let plan = VersionPlanner::plan_releases_with_behavior(
95 &changesets,
96 project.packages(),
97 None,
98 root_config.zero_version_behavior(),
99 )?;
100
101 let graph = self.project_provider.build_dependency_graph(&project)?;
102
103 let projected_releases = super::release::expand_with_reverse_dependencies(
104 plan.releases().clone(),
105 &graph,
106 project.packages(),
107 root_config.zero_version_behavior(),
108 )?;
109
110 let (_, unchanged_packages) =
111 VersionPlanner::partition_packages(&changesets, project.packages());
112
113 let packages_with_inherited_versions = self
114 .inherited_checker
115 .find_packages_with_inherited_versions(project.packages())?;
116
117 let mut none_bump_packages: Vec<String> = bumps_by_package
118 .iter()
119 .filter(|(_, bumps)| max_bump_type(bumps).is_some_and(|b| b.is_noop()))
120 .map(|(name, _)| name.clone())
121 .collect();
122 none_bump_packages.sort();
123
124 let uncovered_dependents =
125 Self::compute_uncovered_dependents(&graph, &projected_releases, &none_bump_packages);
126
127 Ok(StatusOutput {
128 changesets,
129 changeset_files,
130 projected_releases,
131 bumps_by_package,
132 none_bump_packages,
133 unchanged_packages,
134 packages_with_inherited_versions,
135 unknown_packages: plan.unknown_packages().clone(),
136 consumed_prerelease_changesets,
137 uncovered_dependents,
138 })
139 }
140
141 fn compute_uncovered_dependents(
142 graph: &WorkspaceDependencyGraph,
143 releases: &[PackageVersion],
144 none_bump_packages: &[String],
145 ) -> Vec<(String, Vec<String>)> {
146 let covered: Vec<String> = releases
147 .iter()
148 .map(|r| r.name().clone())
149 .chain(none_bump_packages.iter().cloned())
150 .collect();
151
152 let covered_refs: Vec<&str> = covered.iter().map(String::as_str).collect();
153 let uncovered_set = graph.transitive_dependents_of_set(&covered_refs);
154
155 let covered_set: HashSet<&str> = covered_refs.iter().copied().collect();
156
157 let mut result: Vec<(String, Vec<String>)> = uncovered_set
158 .into_iter()
159 .map(|dep_name| {
160 let direct_deps = graph.direct_dependencies(dep_name);
161 let mut relevant: Vec<String> = direct_deps
162 .into_iter()
163 .filter(|d| covered_set.contains(d))
164 .map(str::to_string)
165 .collect();
166 relevant.sort();
167 (dep_name.to_string(), relevant)
168 })
169 .collect();
170
171 result.retain(|(_, deps)| !deps.is_empty());
172 result.sort_by(|(a, _), (b, _)| a.cmp(b));
173
174 result
175 }
176
177 fn collect_consumed_changesets(
178 reader: &R,
179 paths: &[PathBuf],
180 ) -> Result<Vec<(PathBuf, String)>> {
181 let mut consumed = Vec::new();
182 for path in paths {
183 let changeset = reader.read_changeset(path)?;
184 if let Some(version) = changeset.consumed_for_prerelease().cloned() {
185 consumed.push((path.clone(), version));
186 }
187 }
188 Ok(consumed)
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use crate::mocks::{
196 FailingInheritedVersionChecker, MockChangesetReader, MockInheritedVersionChecker,
197 MockProjectProvider, make_changeset,
198 };
199 use crate::traits::DependencyGraphProvider;
200 use changeset_core::BumpType;
201 use semver::Version;
202 use std::path::PathBuf;
203
204 fn make_operation<P, R>(
205 project_provider: P,
206 changeset_reader: R,
207 ) -> StatusOperation<P, R, MockInheritedVersionChecker>
208 where
209 P: ProjectProvider + DependencyGraphProvider,
210 R: ChangesetReader,
211 {
212 StatusOperation::new(
213 project_provider,
214 changeset_reader,
215 MockInheritedVersionChecker::new(),
216 )
217 }
218
219 #[test]
220 fn returns_empty_when_no_changesets() {
221 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
222 let changeset_reader = MockChangesetReader::new();
223
224 let operation = make_operation(project_provider, changeset_reader);
225
226 let result = operation
227 .execute(Path::new("/any"))
228 .expect("StatusOperation failed for project with no changesets");
229
230 assert!(result.changesets().is_empty());
231 assert!(result.changeset_files().is_empty());
232 assert!(result.projected_releases().is_empty());
233 assert!(result.bumps_by_package().is_empty());
234 assert_eq!(result.unchanged_packages().len(), 1);
235 assert_eq!(result.unchanged_packages()[0].name(), "my-crate");
236 assert!(result.packages_with_inherited_versions().is_empty());
237 assert!(result.unknown_packages().is_empty());
238 }
239
240 #[test]
241 fn collects_changesets_and_projected_releases() {
242 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
243
244 let changeset = make_changeset("my-crate", BumpType::Minor, "Add feature");
245 let changeset_reader = MockChangesetReader::new()
246 .with_changeset(PathBuf::from(".changeset/changesets/test.md"), changeset);
247
248 let operation = make_operation(project_provider, changeset_reader);
249
250 let result = operation
251 .execute(Path::new("/any"))
252 .expect("StatusOperation failed to collect changesets");
253
254 assert_eq!(result.changesets().len(), 1);
255 assert_eq!(result.changeset_files().len(), 1);
256 assert!(result.bumps_by_package().contains_key("my-crate"));
257 assert_eq!(result.bumps_by_package()["my-crate"], vec![BumpType::Minor]);
258 assert!(result.unchanged_packages().is_empty());
259
260 assert_eq!(result.projected_releases().len(), 1);
261 let release = &result.projected_releases()[0];
262 assert_eq!(release.name(), "my-crate");
263 assert_eq!(*release.current_version(), Version::new(1, 0, 0));
264 assert_eq!(*release.new_version(), Version::new(1, 1, 0));
265 assert_eq!(release.bump_type(), BumpType::Minor);
266 }
267
268 #[test]
269 fn aggregates_multiple_changesets_for_same_package() {
270 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
271
272 let changeset1 = make_changeset("my-crate", BumpType::Patch, "Fix bug");
273 let changeset2 = make_changeset("my-crate", BumpType::Minor, "Add feature");
274
275 let changeset_reader = MockChangesetReader::new().with_changesets(vec![
276 (PathBuf::from(".changeset/changesets/fix.md"), changeset1),
277 (
278 PathBuf::from(".changeset/changesets/feature.md"),
279 changeset2,
280 ),
281 ]);
282
283 let operation = make_operation(project_provider, changeset_reader);
284
285 let result = operation
286 .execute(Path::new("/any"))
287 .expect("StatusOperation failed to aggregate multiple changesets");
288
289 assert_eq!(result.changesets().len(), 2);
290 assert_eq!(result.bumps_by_package()["my-crate"].len(), 2);
291 assert!(result.bumps_by_package()["my-crate"].contains(&BumpType::Patch));
292 assert!(result.bumps_by_package()["my-crate"].contains(&BumpType::Minor));
293
294 assert_eq!(result.projected_releases().len(), 1);
295 let release = &result.projected_releases()[0];
296 assert_eq!(*release.new_version(), Version::new(1, 1, 0));
297 assert_eq!(release.bump_type(), BumpType::Minor);
298 }
299
300 #[test]
301 fn identifies_unchanged_packages_in_workspace() {
302 let project_provider =
303 MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.0.0")]);
304
305 let changeset = make_changeset("crate-a", BumpType::Patch, "Fix crate-a");
306 let changeset_reader = MockChangesetReader::new()
307 .with_changeset(PathBuf::from(".changeset/changesets/test.md"), changeset);
308
309 let operation = make_operation(project_provider, changeset_reader);
310
311 let result = operation
312 .execute(Path::new("/any"))
313 .expect("StatusOperation failed to identify unchanged packages");
314
315 assert_eq!(result.unchanged_packages().len(), 1);
316 assert_eq!(result.unchanged_packages()[0].name(), "crate-b");
317 }
318
319 #[test]
320 fn detects_packages_with_inherited_versions() {
321 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
322 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix");
323 let changeset_reader = MockChangesetReader::new()
324 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
325
326 let inherited_checker = MockInheritedVersionChecker::new()
327 .with_inherited(vec![PathBuf::from("/mock/project/Cargo.toml")]);
328
329 let operation = StatusOperation::new(project_provider, changeset_reader, inherited_checker);
330
331 let result = operation
332 .execute(Path::new("/any"))
333 .expect("StatusOperation failed to detect inherited versions");
334
335 assert_eq!(
336 result.packages_with_inherited_versions(),
337 &vec!["my-crate".to_string()]
338 );
339 }
340
341 #[test]
342 fn collects_unknown_packages_as_warning() {
343 let project_provider = MockProjectProvider::single_package("known-crate", "1.0.0");
344 let changeset = make_changeset("unknown-crate", BumpType::Patch, "Fix");
345 let changeset_reader = MockChangesetReader::new()
346 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
347
348 let operation = make_operation(project_provider, changeset_reader);
349
350 let result = operation
351 .execute(Path::new("/any"))
352 .expect("StatusOperation failed to collect unknown packages");
353
354 assert!(result.projected_releases().is_empty());
355 assert_eq!(
356 result.unknown_packages(),
357 &vec!["unknown-crate".to_string()]
358 );
359 }
360
361 #[test]
362 fn projected_releases_match_version_planner_output() {
363 let project_provider =
364 MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.5.3")]);
365
366 let changeset1 = make_changeset("crate-a", BumpType::Minor, "Add feature");
367 let changeset2 = make_changeset("crate-b", BumpType::Major, "Breaking change");
368
369 let changeset_reader = MockChangesetReader::new().with_changesets(vec![
370 (
371 PathBuf::from(".changeset/changesets/feature.md"),
372 changeset1,
373 ),
374 (
375 PathBuf::from(".changeset/changesets/breaking.md"),
376 changeset2,
377 ),
378 ]);
379
380 let operation = make_operation(project_provider, changeset_reader);
381
382 let result = operation
383 .execute(Path::new("/any"))
384 .expect("StatusOperation failed");
385
386 assert_eq!(result.projected_releases().len(), 2);
387
388 let release_a = result
389 .projected_releases()
390 .iter()
391 .find(|r| r.name() == "crate-a")
392 .expect("crate-a should be in releases");
393 assert_eq!(*release_a.current_version(), Version::new(1, 0, 0));
394 assert_eq!(*release_a.new_version(), Version::new(1, 1, 0));
395
396 let release_b = result
397 .projected_releases()
398 .iter()
399 .find(|r| r.name() == "crate-b")
400 .expect("crate-b should be in releases");
401 assert_eq!(*release_b.current_version(), Version::new(2, 5, 3));
402 assert_eq!(*release_b.new_version(), Version::new(3, 0, 0));
403 }
404
405 #[test]
406 fn propagates_inherited_version_checker_errors() {
407 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
408 let changeset_reader = MockChangesetReader::new();
409
410 let operation = StatusOperation::new(
411 project_provider,
412 changeset_reader,
413 FailingInheritedVersionChecker,
414 );
415
416 let result = operation.execute(Path::new("/any"));
417
418 assert!(result.is_err());
419 }
420
421 #[test]
422 fn returns_empty_consumed_changesets_when_none_exist() {
423 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
424 let changeset_reader = MockChangesetReader::new();
425
426 let operation = make_operation(project_provider, changeset_reader);
427
428 let result = operation
429 .execute(Path::new("/any"))
430 .expect("StatusOperation failed");
431
432 assert!(result.consumed_prerelease_changesets().is_empty());
433 }
434
435 #[test]
436 fn collects_consumed_prerelease_changesets() {
437 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
438
439 let mut consumed_changeset = make_changeset("my-crate", BumpType::Patch, "Fix bug");
440 consumed_changeset.set_consumed_for_prerelease(Some("1.0.1-alpha.1".to_string()));
441
442 let changeset_reader = MockChangesetReader::new().with_changeset(
443 PathBuf::from(".changeset/changesets/fix-bug.md"),
444 consumed_changeset,
445 );
446
447 let operation = make_operation(project_provider, changeset_reader);
448
449 let result = operation
450 .execute(Path::new("/any"))
451 .expect("StatusOperation failed");
452
453 assert!(result.changeset_files().is_empty());
454 assert!(result.changesets().is_empty());
455 assert_eq!(result.consumed_prerelease_changesets().len(), 1);
456 assert_eq!(
457 result.consumed_prerelease_changesets()[0].0,
458 PathBuf::from(".changeset/changesets/fix-bug.md")
459 );
460 assert_eq!(
461 result.consumed_prerelease_changesets()[0].1,
462 "1.0.1-alpha.1"
463 );
464 }
465
466 #[test]
467 fn separates_pending_and_consumed_changesets() {
468 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
469
470 let pending_changeset = make_changeset("my-crate", BumpType::Minor, "Add feature");
471
472 let mut consumed_changeset = make_changeset("my-crate", BumpType::Patch, "Fix bug");
473 consumed_changeset.set_consumed_for_prerelease(Some("1.0.1-alpha.1".to_string()));
474
475 let changeset_reader = MockChangesetReader::new().with_changesets(vec![
476 (
477 PathBuf::from(".changeset/changesets/feature.md"),
478 pending_changeset,
479 ),
480 (
481 PathBuf::from(".changeset/changesets/fix.md"),
482 consumed_changeset,
483 ),
484 ]);
485
486 let operation = make_operation(project_provider, changeset_reader);
487
488 let result = operation
489 .execute(Path::new("/any"))
490 .expect("StatusOperation failed");
491
492 assert_eq!(result.changeset_files().len(), 1);
493 assert_eq!(
494 result.changeset_files()[0],
495 PathBuf::from(".changeset/changesets/feature.md")
496 );
497
498 assert_eq!(result.changesets().len(), 1);
499 assert_eq!(result.changesets()[0].summary(), "Add feature");
500
501 assert_eq!(result.consumed_prerelease_changesets().len(), 1);
502 assert_eq!(
503 result.consumed_prerelease_changesets()[0].0,
504 PathBuf::from(".changeset/changesets/fix.md")
505 );
506 assert_eq!(
507 result.consumed_prerelease_changesets()[0].1,
508 "1.0.1-alpha.1"
509 );
510 }
511
512 #[test]
513 fn collects_multiple_consumed_changesets_with_different_versions() {
514 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
515
516 let mut consumed1 = make_changeset("my-crate", BumpType::Patch, "Fix bug 1");
517 consumed1.set_consumed_for_prerelease(Some("1.0.1-alpha.1".to_string()));
518
519 let mut consumed2 = make_changeset("my-crate", BumpType::Patch, "Fix bug 2");
520 consumed2.set_consumed_for_prerelease(Some("1.0.1-alpha.2".to_string()));
521
522 let changeset_reader = MockChangesetReader::new().with_changesets(vec![
523 (PathBuf::from(".changeset/changesets/fix1.md"), consumed1),
524 (PathBuf::from(".changeset/changesets/fix2.md"), consumed2),
525 ]);
526
527 let operation = make_operation(project_provider, changeset_reader);
528
529 let result = operation
530 .execute(Path::new("/any"))
531 .expect("StatusOperation failed");
532
533 assert!(result.changeset_files().is_empty());
534 assert_eq!(result.consumed_prerelease_changesets().len(), 2);
535
536 let versions: Vec<&str> = result
537 .consumed_prerelease_changesets()
538 .iter()
539 .map(|(_, v)| v.as_str())
540 .collect();
541 assert!(versions.contains(&"1.0.1-alpha.1"));
542 assert!(versions.contains(&"1.0.1-alpha.2"));
543 }
544
545 #[test]
546 fn dependents_auto_bumped_for_workspace_with_dependencies() {
547 let project_provider =
548 MockProjectProvider::workspace(vec![("core", "1.0.0"), ("app", "1.0.0")])
549 .with_dependency_edges(vec![("app", "core")]);
550
551 let changeset = make_changeset("core", BumpType::Patch, "Fix core");
552 let changeset_reader = MockChangesetReader::new()
553 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
554
555 let operation = make_operation(project_provider, changeset_reader);
556
557 let result = operation
558 .execute(Path::new("/any"))
559 .expect("StatusOperation failed");
560
561 assert!(
562 result.uncovered_dependents().is_empty(),
563 "dependents are auto-bumped, none should be uncovered"
564 );
565
566 let app_release = result
567 .projected_releases()
568 .iter()
569 .find(|r| r.name() == "app")
570 .expect("app should be auto-bumped into projected releases");
571 assert!(app_release.auto_bumped());
572 assert_eq!(app_release.bump_type(), BumpType::Patch);
573 }
574
575 #[test]
576 fn covered_dependents_not_listed_as_uncovered() {
577 let project_provider =
578 MockProjectProvider::workspace(vec![("core", "1.0.0"), ("app", "1.0.0")])
579 .with_dependency_edges(vec![("app", "core")]);
580
581 let changeset1 = make_changeset("core", BumpType::Patch, "Fix core");
582 let changeset2 = make_changeset("app", BumpType::Patch, "Fix app");
583 let changeset_reader = MockChangesetReader::new().with_changesets(vec![
584 (
585 PathBuf::from(".changeset/changesets/fix-core.md"),
586 changeset1,
587 ),
588 (
589 PathBuf::from(".changeset/changesets/fix-app.md"),
590 changeset2,
591 ),
592 ]);
593
594 let operation = make_operation(project_provider, changeset_reader);
595
596 let result = operation
597 .execute(Path::new("/any"))
598 .expect("StatusOperation failed");
599
600 assert!(
601 result.uncovered_dependents().is_empty(),
602 "all dependents are covered, none should appear"
603 );
604 }
605
606 #[test]
607 fn single_package_has_no_uncovered_dependents() {
608 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
609
610 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix");
611 let changeset_reader = MockChangesetReader::new()
612 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
613
614 let operation = make_operation(project_provider, changeset_reader);
615
616 let result = operation
617 .execute(Path::new("/any"))
618 .expect("StatusOperation failed");
619
620 assert!(result.uncovered_dependents().is_empty());
621 }
622
623 #[test]
624 fn no_changesets_means_no_uncovered_dependents() {
625 let project_provider =
626 MockProjectProvider::workspace(vec![("core", "1.0.0"), ("app", "1.0.0")])
627 .with_dependency_edges(vec![("app", "core")]);
628
629 let changeset_reader = MockChangesetReader::new();
630
631 let operation = make_operation(project_provider, changeset_reader);
632
633 let result = operation
634 .execute(Path::new("/any"))
635 .expect("StatusOperation failed");
636
637 assert!(result.uncovered_dependents().is_empty());
638 }
639
640 #[test]
641 fn multiple_dependents_auto_bumped() {
642 let project_provider = MockProjectProvider::workspace(vec![
643 ("core", "1.0.0"),
644 ("zebra", "1.0.0"),
645 ("alpha", "1.0.0"),
646 ])
647 .with_dependency_edges(vec![("zebra", "core"), ("alpha", "core")]);
648
649 let changeset = make_changeset("core", BumpType::Patch, "Fix core");
650 let changeset_reader = MockChangesetReader::new()
651 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
652
653 let operation = make_operation(project_provider, changeset_reader);
654
655 let result = operation
656 .execute(Path::new("/any"))
657 .expect("StatusOperation failed");
658
659 assert!(
660 result.uncovered_dependents().is_empty(),
661 "all dependents are auto-bumped"
662 );
663
664 let auto_bumped: Vec<&str> = result
665 .projected_releases()
666 .iter()
667 .filter(|r| r.auto_bumped())
668 .map(|r| r.name().as_str())
669 .collect();
670 assert!(auto_bumped.contains(&"alpha"));
671 assert!(auto_bumped.contains(&"zebra"));
672 }
673
674 #[test]
675 fn transitive_chain_auto_bumps_all_dependents() {
676 let project_provider =
677 MockProjectProvider::workspace(vec![("a", "1.0.0"), ("b", "1.0.0"), ("c", "1.0.0")])
678 .with_dependency_edges(vec![("a", "b"), ("b", "c")]);
679
680 let changeset = make_changeset("c", BumpType::Patch, "Fix c");
681 let changeset_reader = MockChangesetReader::new()
682 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
683
684 let operation = make_operation(project_provider, changeset_reader);
685
686 let result = operation
687 .execute(Path::new("/any"))
688 .expect("StatusOperation failed");
689
690 assert!(
691 result.uncovered_dependents().is_empty(),
692 "all transitive dependents are auto-bumped"
693 );
694
695 let auto_bumped: Vec<&str> = result
696 .projected_releases()
697 .iter()
698 .filter(|r| r.auto_bumped())
699 .map(|r| r.name().as_str())
700 .collect();
701 assert!(auto_bumped.contains(&"a"));
702 assert!(auto_bumped.contains(&"b"));
703 }
704
705 #[test]
706 fn auto_bumped_dependents_appear_in_projected_releases() {
707 let project_provider =
708 MockProjectProvider::workspace(vec![("core", "1.0.0"), ("app", "1.0.0")])
709 .with_dependency_edges(vec![("app", "core")]);
710
711 let changeset = make_changeset("core", BumpType::Minor, "Add feature to core");
712 let changeset_reader = MockChangesetReader::new()
713 .with_changeset(PathBuf::from(".changeset/changesets/feature.md"), changeset);
714
715 let operation = make_operation(project_provider, changeset_reader);
716
717 let result = operation
718 .execute(Path::new("/any"))
719 .expect("StatusOperation failed");
720
721 assert_eq!(result.projected_releases().len(), 2);
722
723 let core_release = result
724 .projected_releases()
725 .iter()
726 .find(|r| r.name() == "core")
727 .expect("core should be in projected releases");
728 assert_eq!(*core_release.new_version(), Version::new(1, 1, 0));
729 assert!(!core_release.auto_bumped());
730
731 let app_release = result
732 .projected_releases()
733 .iter()
734 .find(|r| r.name() == "app")
735 .expect("app should be auto-bumped into projected releases");
736 assert_eq!(*app_release.new_version(), Version::new(1, 0, 1));
737 assert_eq!(app_release.bump_type(), BumpType::Patch);
738 assert!(app_release.auto_bumped());
739 }
740
741 #[test]
742 fn none_bump_dependent_excluded_from_uncovered() {
743 let project_provider =
744 MockProjectProvider::workspace(vec![("core", "1.0.0"), ("app", "1.0.0")])
745 .with_dependency_edges(vec![("app", "core")]);
746
747 let changeset1 = make_changeset("core", BumpType::Patch, "Fix core");
748 let changeset2 = make_changeset("app", BumpType::None, "No version bump for app");
749 let changeset_reader = MockChangesetReader::new().with_changesets(vec![
750 (
751 PathBuf::from(".changeset/changesets/fix-core.md"),
752 changeset1,
753 ),
754 (
755 PathBuf::from(".changeset/changesets/none-app.md"),
756 changeset2,
757 ),
758 ]);
759
760 let operation = make_operation(project_provider, changeset_reader);
761
762 let result = operation
763 .execute(Path::new("/any"))
764 .expect("StatusOperation failed");
765
766 assert!(
767 result.uncovered_dependents().is_empty(),
768 "app is covered by a none-bump changeset and should not appear as uncovered"
769 );
770 }
771
772 #[test]
773 fn promote_to_patch_shows_patch_not_none() {
774 use changeset_core::NoneBumpBehavior;
775 use changeset_project::RootChangesetConfig;
776
777 let custom_config = RootChangesetConfig::default()
778 .with_none_bump_behavior(NoneBumpBehavior::PromoteToPatch);
779 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
780 .with_root_config(custom_config);
781
782 let changeset = make_changeset("my-crate", BumpType::None, "Internal refactor");
783 let changeset_reader = MockChangesetReader::new().with_changeset(
784 PathBuf::from(".changeset/changesets/refactor.md"),
785 changeset,
786 );
787
788 let operation = make_operation(project_provider, changeset_reader);
789
790 let result = operation
791 .execute(Path::new("/any"))
792 .expect("StatusOperation failed");
793
794 assert!(
795 result.none_bump_packages().is_empty(),
796 "promoted None bumps should not appear in none_bump_packages"
797 );
798 assert_eq!(result.projected_releases().len(), 1);
799 assert_eq!(result.projected_releases()[0].bump_type(), BumpType::Patch);
800 }
801
802 #[test]
803 fn disallow_errors_in_status() {
804 use changeset_core::NoneBumpBehavior;
805 use changeset_project::RootChangesetConfig;
806
807 let custom_config =
808 RootChangesetConfig::default().with_none_bump_behavior(NoneBumpBehavior::Disallow);
809 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
810 .with_root_config(custom_config);
811
812 let changeset = make_changeset("my-crate", BumpType::None, "Internal refactor");
813 let changeset_reader = MockChangesetReader::new().with_changeset(
814 PathBuf::from(".changeset/changesets/refactor.md"),
815 changeset,
816 );
817
818 let operation = make_operation(project_provider, changeset_reader);
819
820 let result = operation.execute(Path::new("/any"));
821
822 assert!(matches!(
823 result,
824 Err(crate::error::OperationError::NoneBumpDisallowed { .. })
825 ));
826 }
827
828 #[test]
829 fn allow_passes_none_bumps_through() {
830 use changeset_core::NoneBumpBehavior;
831 use changeset_project::RootChangesetConfig;
832
833 let custom_config =
834 RootChangesetConfig::default().with_none_bump_behavior(NoneBumpBehavior::Allow);
835 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
836 .with_root_config(custom_config);
837
838 let changeset = make_changeset("my-crate", BumpType::None, "Internal refactor");
839 let changeset_reader = MockChangesetReader::new().with_changeset(
840 PathBuf::from(".changeset/changesets/refactor.md"),
841 changeset,
842 );
843
844 let operation = make_operation(project_provider, changeset_reader);
845
846 let result = operation
847 .execute(Path::new("/any"))
848 .expect("StatusOperation failed");
849
850 assert!(
851 result
852 .none_bump_packages()
853 .contains(&"my-crate".to_string()),
854 "my-crate should appear in none_bump_packages with Allow behavior"
855 );
856 assert!(
857 result.projected_releases().is_empty(),
858 "None bump with Allow should not produce projected releases"
859 );
860 }
861}