Skip to main content

changeset_operations/operations/
status.rs

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    /// # Errors
64    ///
65    /// Returns an error if the project cannot be discovered or if changeset files
66    /// cannot be read.
67    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}