Skip to main content

changeset_operations/operations/
status.rs

1use std::path::{Path, PathBuf};
2
3use changeset_core::{BumpType, Changeset, PackageInfo};
4use indexmap::IndexMap;
5
6use crate::Result;
7use crate::planner::VersionPlanner;
8use crate::traits::{ChangesetReader, InheritedVersionChecker, ProjectProvider};
9use crate::types::PackageVersion;
10
11pub struct StatusOutput {
12    /// All parsed changesets.
13    pub changesets: Vec<Changeset>,
14    /// Paths to changeset files.
15    pub changeset_files: Vec<PathBuf>,
16    /// Calculated releases (same type as `ReleaseOperation` uses).
17    pub projected_releases: Vec<PackageVersion>,
18    /// Raw bump types per package (for verbose display).
19    pub bumps_by_package: IndexMap<String, Vec<BumpType>>,
20    /// Packages with no pending changesets.
21    pub unchanged_packages: Vec<PackageInfo>,
22    /// Packages using inherited versions (informational warning).
23    pub packages_with_inherited_versions: Vec<String>,
24    /// Packages referenced in changesets but not in workspace.
25    pub unknown_packages: Vec<String>,
26    /// Changesets consumed for pre-release versions (path, version consumed for).
27    pub consumed_prerelease_changesets: Vec<(PathBuf, String)>,
28}
29
30pub struct StatusOperation<P, R, I> {
31    project_provider: P,
32    changeset_reader: R,
33    inherited_checker: I,
34}
35
36impl<P, R, I> StatusOperation<P, R, I>
37where
38    P: ProjectProvider,
39    R: ChangesetReader,
40    I: InheritedVersionChecker,
41{
42    pub fn new(project_provider: P, changeset_reader: R, inherited_checker: I) -> Self {
43        Self {
44            project_provider,
45            changeset_reader,
46            inherited_checker,
47        }
48    }
49
50    /// # Errors
51    ///
52    /// Returns an error if the project cannot be discovered or if changeset files
53    /// cannot be read.
54    pub fn execute(&self, start_path: &Path) -> Result<StatusOutput> {
55        let project = self.project_provider.discover_project(start_path)?;
56        let (root_config, _) = self.project_provider.load_configs(&project)?;
57
58        let changeset_dir = project.root().join(root_config.changeset_dir());
59        let changeset_files = self.changeset_reader.list_changesets(&changeset_dir)?;
60
61        let mut changesets = Vec::new();
62        for path in &changeset_files {
63            let changeset = self.changeset_reader.read_changeset(path)?;
64            changesets.push(changeset);
65        }
66
67        let consumed_changeset_paths = self
68            .changeset_reader
69            .list_consumed_changesets(&changeset_dir)?;
70        let consumed_prerelease_changesets =
71            Self::collect_consumed_changesets(&self.changeset_reader, &consumed_changeset_paths)?;
72
73        let bumps_by_package = VersionPlanner::aggregate_bumps(&changesets);
74
75        let plan = VersionPlanner::plan_releases_with_behavior(
76            &changesets,
77            project.packages(),
78            None,
79            root_config.zero_version_behavior(),
80        )?;
81
82        let (_, unchanged_packages) =
83            VersionPlanner::partition_packages(&changesets, project.packages());
84
85        let packages_with_inherited_versions = self
86            .inherited_checker
87            .find_packages_with_inherited_versions(project.packages())?;
88
89        Ok(StatusOutput {
90            changesets,
91            changeset_files,
92            projected_releases: plan.releases,
93            bumps_by_package,
94            unchanged_packages,
95            packages_with_inherited_versions,
96            unknown_packages: plan.unknown_packages,
97            consumed_prerelease_changesets,
98        })
99    }
100
101    fn collect_consumed_changesets(
102        reader: &R,
103        paths: &[PathBuf],
104    ) -> Result<Vec<(PathBuf, String)>> {
105        let mut consumed = Vec::new();
106        for path in paths {
107            let changeset = reader.read_changeset(path)?;
108            if let Some(version) = changeset.consumed_for_prerelease {
109                consumed.push((path.clone(), version));
110            }
111        }
112        Ok(consumed)
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::mocks::{
120        FailingInheritedVersionChecker, MockChangesetReader, MockInheritedVersionChecker,
121        MockProjectProvider, make_changeset,
122    };
123    use changeset_core::BumpType;
124    use semver::Version;
125    use std::path::PathBuf;
126
127    fn make_operation<P, R>(
128        project_provider: P,
129        changeset_reader: R,
130    ) -> StatusOperation<P, R, MockInheritedVersionChecker>
131    where
132        P: ProjectProvider,
133        R: ChangesetReader,
134    {
135        StatusOperation::new(
136            project_provider,
137            changeset_reader,
138            MockInheritedVersionChecker::new(),
139        )
140    }
141
142    #[test]
143    fn returns_empty_when_no_changesets() {
144        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
145        let changeset_reader = MockChangesetReader::new();
146
147        let operation = make_operation(project_provider, changeset_reader);
148
149        let result = operation
150            .execute(Path::new("/any"))
151            .expect("StatusOperation failed for project with no changesets");
152
153        assert!(result.changesets.is_empty());
154        assert!(result.changeset_files.is_empty());
155        assert!(result.projected_releases.is_empty());
156        assert!(result.bumps_by_package.is_empty());
157        assert_eq!(result.unchanged_packages.len(), 1);
158        assert_eq!(result.unchanged_packages[0].name, "my-crate");
159        assert!(result.packages_with_inherited_versions.is_empty());
160        assert!(result.unknown_packages.is_empty());
161    }
162
163    #[test]
164    fn collects_changesets_and_projected_releases() {
165        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
166
167        let changeset = make_changeset("my-crate", BumpType::Minor, "Add feature");
168        let changeset_reader = MockChangesetReader::new()
169            .with_changeset(PathBuf::from(".changeset/changesets/test.md"), changeset);
170
171        let operation = make_operation(project_provider, changeset_reader);
172
173        let result = operation
174            .execute(Path::new("/any"))
175            .expect("StatusOperation failed to collect changesets");
176
177        assert_eq!(result.changesets.len(), 1);
178        assert_eq!(result.changeset_files.len(), 1);
179        assert!(result.bumps_by_package.contains_key("my-crate"));
180        assert_eq!(result.bumps_by_package["my-crate"], vec![BumpType::Minor]);
181        assert!(result.unchanged_packages.is_empty());
182
183        assert_eq!(result.projected_releases.len(), 1);
184        let release = &result.projected_releases[0];
185        assert_eq!(release.name, "my-crate");
186        assert_eq!(release.current_version, Version::new(1, 0, 0));
187        assert_eq!(release.new_version, Version::new(1, 1, 0));
188        assert_eq!(release.bump_type, BumpType::Minor);
189    }
190
191    #[test]
192    fn aggregates_multiple_changesets_for_same_package() {
193        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
194
195        let changeset1 = make_changeset("my-crate", BumpType::Patch, "Fix bug");
196        let changeset2 = make_changeset("my-crate", BumpType::Minor, "Add feature");
197
198        let changeset_reader = MockChangesetReader::new().with_changesets(vec![
199            (PathBuf::from(".changeset/changesets/fix.md"), changeset1),
200            (
201                PathBuf::from(".changeset/changesets/feature.md"),
202                changeset2,
203            ),
204        ]);
205
206        let operation = make_operation(project_provider, changeset_reader);
207
208        let result = operation
209            .execute(Path::new("/any"))
210            .expect("StatusOperation failed to aggregate multiple changesets");
211
212        assert_eq!(result.changesets.len(), 2);
213        assert_eq!(result.bumps_by_package["my-crate"].len(), 2);
214        assert!(result.bumps_by_package["my-crate"].contains(&BumpType::Patch));
215        assert!(result.bumps_by_package["my-crate"].contains(&BumpType::Minor));
216
217        assert_eq!(result.projected_releases.len(), 1);
218        let release = &result.projected_releases[0];
219        assert_eq!(release.new_version, Version::new(1, 1, 0));
220        assert_eq!(release.bump_type, BumpType::Minor);
221    }
222
223    #[test]
224    fn identifies_unchanged_packages_in_workspace() {
225        let project_provider =
226            MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.0.0")]);
227
228        let changeset = make_changeset("crate-a", BumpType::Patch, "Fix crate-a");
229        let changeset_reader = MockChangesetReader::new()
230            .with_changeset(PathBuf::from(".changeset/changesets/test.md"), changeset);
231
232        let operation = make_operation(project_provider, changeset_reader);
233
234        let result = operation
235            .execute(Path::new("/any"))
236            .expect("StatusOperation failed to identify unchanged packages");
237
238        assert_eq!(result.unchanged_packages.len(), 1);
239        assert_eq!(result.unchanged_packages[0].name, "crate-b");
240    }
241
242    #[test]
243    fn detects_packages_with_inherited_versions() {
244        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
245        let changeset = make_changeset("my-crate", BumpType::Patch, "Fix");
246        let changeset_reader = MockChangesetReader::new()
247            .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
248
249        let inherited_checker = MockInheritedVersionChecker::new()
250            .with_inherited(vec![PathBuf::from("/mock/project/Cargo.toml")]);
251
252        let operation = StatusOperation::new(project_provider, changeset_reader, inherited_checker);
253
254        let result = operation
255            .execute(Path::new("/any"))
256            .expect("StatusOperation failed to detect inherited versions");
257
258        assert_eq!(result.packages_with_inherited_versions, vec!["my-crate"]);
259    }
260
261    #[test]
262    fn collects_unknown_packages_as_warning() {
263        let project_provider = MockProjectProvider::single_package("known-crate", "1.0.0");
264        let changeset = make_changeset("unknown-crate", BumpType::Patch, "Fix");
265        let changeset_reader = MockChangesetReader::new()
266            .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
267
268        let operation = make_operation(project_provider, changeset_reader);
269
270        let result = operation
271            .execute(Path::new("/any"))
272            .expect("StatusOperation failed to collect unknown packages");
273
274        assert!(result.projected_releases.is_empty());
275        assert_eq!(result.unknown_packages, vec!["unknown-crate"]);
276    }
277
278    #[test]
279    fn projected_releases_match_version_planner_output() {
280        let project_provider =
281            MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.5.3")]);
282
283        let changeset1 = make_changeset("crate-a", BumpType::Minor, "Add feature");
284        let changeset2 = make_changeset("crate-b", BumpType::Major, "Breaking change");
285
286        let changeset_reader = MockChangesetReader::new().with_changesets(vec![
287            (
288                PathBuf::from(".changeset/changesets/feature.md"),
289                changeset1,
290            ),
291            (
292                PathBuf::from(".changeset/changesets/breaking.md"),
293                changeset2,
294            ),
295        ]);
296
297        let operation = make_operation(project_provider, changeset_reader);
298
299        let result = operation
300            .execute(Path::new("/any"))
301            .expect("StatusOperation failed");
302
303        assert_eq!(result.projected_releases.len(), 2);
304
305        let release_a = result
306            .projected_releases
307            .iter()
308            .find(|r| r.name == "crate-a")
309            .expect("crate-a should be in releases");
310        assert_eq!(release_a.current_version, Version::new(1, 0, 0));
311        assert_eq!(release_a.new_version, Version::new(1, 1, 0));
312
313        let release_b = result
314            .projected_releases
315            .iter()
316            .find(|r| r.name == "crate-b")
317            .expect("crate-b should be in releases");
318        assert_eq!(release_b.current_version, Version::new(2, 5, 3));
319        assert_eq!(release_b.new_version, Version::new(3, 0, 0));
320    }
321
322    #[test]
323    fn propagates_inherited_version_checker_errors() {
324        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
325        let changeset_reader = MockChangesetReader::new();
326
327        let operation = StatusOperation::new(
328            project_provider,
329            changeset_reader,
330            FailingInheritedVersionChecker,
331        );
332
333        let result = operation.execute(Path::new("/any"));
334
335        assert!(result.is_err());
336    }
337
338    #[test]
339    fn returns_empty_consumed_changesets_when_none_exist() {
340        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
341        let changeset_reader = MockChangesetReader::new();
342
343        let operation = make_operation(project_provider, changeset_reader);
344
345        let result = operation
346            .execute(Path::new("/any"))
347            .expect("StatusOperation failed");
348
349        assert!(result.consumed_prerelease_changesets.is_empty());
350    }
351
352    #[test]
353    fn collects_consumed_prerelease_changesets() {
354        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
355
356        let mut consumed_changeset = make_changeset("my-crate", BumpType::Patch, "Fix bug");
357        consumed_changeset.consumed_for_prerelease = Some("1.0.1-alpha.1".to_string());
358
359        let changeset_reader = MockChangesetReader::new().with_changeset(
360            PathBuf::from(".changeset/changesets/fix-bug.md"),
361            consumed_changeset,
362        );
363
364        let operation = make_operation(project_provider, changeset_reader);
365
366        let result = operation
367            .execute(Path::new("/any"))
368            .expect("StatusOperation failed");
369
370        assert!(result.changeset_files.is_empty());
371        assert!(result.changesets.is_empty());
372        assert_eq!(result.consumed_prerelease_changesets.len(), 1);
373        assert_eq!(
374            result.consumed_prerelease_changesets[0].0,
375            PathBuf::from(".changeset/changesets/fix-bug.md")
376        );
377        assert_eq!(result.consumed_prerelease_changesets[0].1, "1.0.1-alpha.1");
378    }
379
380    #[test]
381    fn separates_pending_and_consumed_changesets() {
382        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
383
384        let pending_changeset = make_changeset("my-crate", BumpType::Minor, "Add feature");
385
386        let mut consumed_changeset = make_changeset("my-crate", BumpType::Patch, "Fix bug");
387        consumed_changeset.consumed_for_prerelease = Some("1.0.1-alpha.1".to_string());
388
389        let changeset_reader = MockChangesetReader::new().with_changesets(vec![
390            (
391                PathBuf::from(".changeset/changesets/feature.md"),
392                pending_changeset,
393            ),
394            (
395                PathBuf::from(".changeset/changesets/fix.md"),
396                consumed_changeset,
397            ),
398        ]);
399
400        let operation = make_operation(project_provider, changeset_reader);
401
402        let result = operation
403            .execute(Path::new("/any"))
404            .expect("StatusOperation failed");
405
406        assert_eq!(result.changeset_files.len(), 1);
407        assert_eq!(
408            result.changeset_files[0],
409            PathBuf::from(".changeset/changesets/feature.md")
410        );
411
412        assert_eq!(result.changesets.len(), 1);
413        assert_eq!(result.changesets[0].summary, "Add feature");
414
415        assert_eq!(result.consumed_prerelease_changesets.len(), 1);
416        assert_eq!(
417            result.consumed_prerelease_changesets[0].0,
418            PathBuf::from(".changeset/changesets/fix.md")
419        );
420        assert_eq!(result.consumed_prerelease_changesets[0].1, "1.0.1-alpha.1");
421    }
422
423    #[test]
424    fn collects_multiple_consumed_changesets_with_different_versions() {
425        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
426
427        let mut consumed1 = make_changeset("my-crate", BumpType::Patch, "Fix bug 1");
428        consumed1.consumed_for_prerelease = Some("1.0.1-alpha.1".to_string());
429
430        let mut consumed2 = make_changeset("my-crate", BumpType::Patch, "Fix bug 2");
431        consumed2.consumed_for_prerelease = Some("1.0.1-alpha.2".to_string());
432
433        let changeset_reader = MockChangesetReader::new().with_changesets(vec![
434            (PathBuf::from(".changeset/changesets/fix1.md"), consumed1),
435            (PathBuf::from(".changeset/changesets/fix2.md"), consumed2),
436        ]);
437
438        let operation = make_operation(project_provider, changeset_reader);
439
440        let result = operation
441            .execute(Path::new("/any"))
442            .expect("StatusOperation failed");
443
444        assert!(result.changeset_files.is_empty());
445        assert_eq!(result.consumed_prerelease_changesets.len(), 2);
446
447        let versions: Vec<&str> = result
448            .consumed_prerelease_changesets
449            .iter()
450            .map(|(_, v)| v.as_str())
451            .collect();
452        assert!(versions.contains(&"1.0.1-alpha.1"));
453        assert!(versions.contains(&"1.0.1-alpha.2"));
454    }
455}