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 pub changesets: Vec<Changeset>,
14 pub changeset_files: Vec<PathBuf>,
16 pub projected_releases: Vec<PackageVersion>,
18 pub bumps_by_package: IndexMap<String, Vec<BumpType>>,
20 pub unchanged_packages: Vec<PackageInfo>,
22 pub packages_with_inherited_versions: Vec<String>,
24 pub unknown_packages: Vec<String>,
26 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 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}