1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use changeset_core::{NoneBumpBehavior, PackageInfo};
5use changeset_git::{FileChange, FileStatus};
6use changeset_project::map_files_to_packages;
7
8use derive_builder::Builder;
9use gset::Getset;
10
11use crate::Result;
12use crate::traits::{
13 ChangesetReader, DependencyGraphProvider, GitDiffProvider, GitStatusProvider,
14 GitWorkdirDiffProvider, ProjectProvider,
15};
16use crate::verification::rules::{CoverageRule, DeletedChangesetsRule, NoneBumpDisallowedRule};
17use crate::verification::{VerificationContext, VerificationEngine, VerificationResult};
18
19#[derive(Builder, Default, Getset)]
20#[builder(default)]
21pub struct VerifyInput {
22 #[getset(get, vis = "pub")]
23 base: String,
24 #[getset(get_as_ref, vis = "pub", ty = "Option<&String>")]
25 head: Option<String>,
26 #[getset(get_copy, vis = "pub")]
27 allow_deleted_changesets: bool,
28 #[getset(get_copy, vis = "pub")]
29 exclude_dependents: bool,
30 #[getset(get_copy, vis = "pub")]
31 ignore_dirty: bool,
32}
33
34#[must_use]
35#[derive(Debug)]
36pub enum VerifyOutcome {
37 Success(VerificationResult),
38 NoChanges,
39 NoPackagesAffected {
40 project_file_count: usize,
41 ignored_file_count: usize,
42 },
43 Failed(VerificationResult),
44}
45
46#[must_use]
47#[derive(Debug, Getset)]
48pub struct VerifyResult {
49 #[getset(get_copy, vis = "pub")]
50 is_dirty: bool,
51 #[getset(get, vis = "pub")]
52 outcome: VerifyOutcome,
53}
54
55impl VerifyResult {
56 pub(crate) fn new(is_dirty: bool, outcome: VerifyOutcome) -> Self {
57 Self { is_dirty, outcome }
58 }
59}
60
61pub struct VerifyOperation<P, G, R> {
62 project_provider: P,
63 git_provider: G,
64 changeset_reader: R,
65}
66
67impl<P, G, R> VerifyOperation<P, G, R>
68where
69 P: ProjectProvider + DependencyGraphProvider,
70 G: GitDiffProvider + GitWorkdirDiffProvider + GitStatusProvider,
71 R: ChangesetReader,
72{
73 pub fn new(project_provider: P, git_provider: G, changeset_reader: R) -> Self {
74 Self {
75 project_provider,
76 git_provider,
77 changeset_reader,
78 }
79 }
80
81 pub fn execute(&self, start_path: &Path, input: &VerifyInput) -> Result<VerifyResult> {
85 let project = self.project_provider.discover_project(start_path)?;
86 let (root_config, package_configs) = self.project_provider.load_configs(&project)?;
87
88 let (is_dirty, changeset_files, deleted_changesets, changed_paths) =
89 self.collect_changes(&project, root_config.changeset_dir(), input)?;
90
91 let has_code_changes = !changed_paths.is_empty();
92 let has_deleted_changesets = !deleted_changesets.is_empty();
93
94 if !has_code_changes && !has_deleted_changesets {
95 return Ok(VerifyResult::new(is_dirty, VerifyOutcome::NoChanges));
96 }
97
98 let mapping = has_code_changes.then(|| {
99 map_files_to_packages(&project, &changed_paths, &root_config, &package_configs)
100 });
101
102 let (affected_packages, transitive_dependents) =
103 self.resolve_affected_packages(&project, mapping.as_ref(), input)?;
104
105 if affected_packages.is_empty() && !has_deleted_changesets {
106 let (project_file_count, ignored_file_count) = mapping
107 .as_ref()
108 .map_or((0, 0), |m| (m.project().len(), m.ignored().len()));
109 return Ok(VerifyResult::new(
110 is_dirty,
111 VerifyOutcome::NoPackagesAffected {
112 project_file_count,
113 ignored_file_count,
114 },
115 ));
116 }
117
118 let context = build_context(
119 mapping.as_ref(),
120 affected_packages,
121 transitive_dependents,
122 changeset_files,
123 deleted_changesets,
124 );
125
126 let result =
127 self.run_verification(&context, input.allow_deleted_changesets(), &root_config)?;
128
129 let outcome = if result.is_success() {
130 VerifyOutcome::Success(result)
131 } else {
132 VerifyOutcome::Failed(result)
133 };
134
135 Ok(VerifyResult::new(is_dirty, outcome))
136 }
137
138 fn collect_changes(
139 &self,
140 project: &changeset_project::CargoProject,
141 changeset_dir: &Path,
142 input: &VerifyInput,
143 ) -> Result<(bool, Vec<PathBuf>, Vec<PathBuf>, Vec<PathBuf>)> {
144 let working_tree_dirty = if input.ignore_dirty() {
145 false
146 } else {
147 !self.git_provider.is_working_tree_clean(project.root())?
148 };
149
150 let changed_files = if working_tree_dirty {
151 self.git_provider.uncommitted_changes(project.root())?
152 } else {
153 let head_ref = input.head().map_or("HEAD", String::as_str);
154 self.git_provider
155 .changed_files(project.root(), input.base(), head_ref)?
156 };
157
158 let is_dirty = working_tree_dirty && !changed_files.is_empty();
159
160 let (changeset_changes, code_changes): (Vec<_>, Vec<_>) = changed_files
161 .into_iter()
162 .partition(|change| change.path().starts_with(changeset_dir));
163
164 let deleted_changesets = extract_deleted_changesets(&changeset_changes, changeset_dir);
165 let changeset_files = extract_active_changesets(&changeset_changes);
166 let changed_paths = code_changes
167 .into_iter()
168 .map(|change| change.path().clone())
169 .collect();
170
171 Ok((is_dirty, changeset_files, deleted_changesets, changed_paths))
172 }
173
174 fn resolve_affected_packages(
175 &self,
176 project: &changeset_project::CargoProject,
177 mapping: Option<&changeset_project::FileMapping>,
178 input: &VerifyInput,
179 ) -> Result<(Vec<PackageInfo>, HashSet<String>)> {
180 let mut affected_packages: Vec<PackageInfo> = mapping.map_or(Vec::new(), |m| {
181 m.affected_packages().into_iter().cloned().collect()
182 });
183
184 let mut transitive_dependents: HashSet<String> = HashSet::new();
185
186 if !input.exclude_dependents()
187 && project.packages().len() > 1
188 && !affected_packages.is_empty()
189 {
190 let graph = self.project_provider.build_dependency_graph(project)?;
191 let affected_names: Vec<&str> = affected_packages
192 .iter()
193 .map(|p| p.name().as_str())
194 .collect();
195 let dependents = graph.transitive_dependents_of_set(&affected_names);
196
197 for pkg in project.packages() {
198 if dependents.contains(pkg.name().as_str())
199 && !affected_packages.iter().any(|p| p.name() == pkg.name())
200 {
201 transitive_dependents.insert(pkg.name().clone());
202 affected_packages.push(pkg.clone());
203 }
204 }
205 }
206
207 Ok((affected_packages, transitive_dependents))
208 }
209
210 fn run_verification(
211 &self,
212 context: &VerificationContext,
213 allow_deleted_changesets: bool,
214 root_config: &changeset_project::RootChangesetConfig,
215 ) -> Result<crate::verification::VerificationResult> {
216 let deleted_rule = DeletedChangesetsRule::new(allow_deleted_changesets);
217 let coverage_rule = CoverageRule::new(&self.changeset_reader);
218 let none_bump_rule = NoneBumpDisallowedRule::new(&self.changeset_reader);
219
220 let mut engine = VerificationEngine::new();
221 engine.add_rule(&deleted_rule);
222 engine.add_rule(&coverage_rule);
223
224 if root_config.none_bump_behavior() == NoneBumpBehavior::Disallow {
225 engine.add_rule(&none_bump_rule);
226 }
227
228 engine.verify(context)
229 }
230}
231
232fn is_markdown_file(path: &Path) -> bool {
233 path.extension().is_some_and(|ext| ext == "md")
234}
235
236fn extract_deleted_changesets(changes: &[FileChange], changeset_dir: &Path) -> Vec<PathBuf> {
237 changes
238 .iter()
239 .filter_map(|change| match change.status() {
240 FileStatus::Deleted if is_markdown_file(change.path()) => Some(change.path().clone()),
241 FileStatus::Renamed => change
242 .old_path()
243 .filter(|old| old.starts_with(changeset_dir) && is_markdown_file(old))
244 .cloned(),
245 _ => None,
246 })
247 .collect()
248}
249
250fn extract_active_changesets(changes: &[FileChange]) -> Vec<PathBuf> {
251 changes
252 .iter()
253 .filter(|change| {
254 is_markdown_file(change.path())
255 && matches!(
256 change.status(),
257 FileStatus::Added
258 | FileStatus::Modified
259 | FileStatus::Renamed
260 | FileStatus::Typechange
261 )
262 })
263 .map(|change| change.path().clone())
264 .collect()
265}
266
267fn build_context(
268 mapping: Option<&changeset_project::FileMapping>,
269 affected_packages: Vec<PackageInfo>,
270 transitive_dependents: HashSet<String>,
271 changeset_files: Vec<PathBuf>,
272 deleted_changesets: Vec<PathBuf>,
273) -> VerificationContext {
274 let (project_files, ignored_files) = mapping.map_or((Vec::new(), Vec::new()), |m| {
275 (m.project().clone(), m.ignored().clone())
276 });
277 VerificationContext::new(
278 affected_packages,
279 transitive_dependents,
280 changeset_files,
281 deleted_changesets,
282 project_files,
283 ignored_files,
284 )
285}
286
287#[cfg(test)]
288mod tests {
289 use std::sync::Arc;
290
291 use super::*;
292 use changeset_core::{BumpType, NoneBumpBehavior};
293 use changeset_git::FileStatus;
294 use changeset_project::RootChangesetConfig;
295
296 use crate::mocks::{MockChangesetReader, MockGitProvider, MockProjectProvider};
297
298 #[test]
299 fn returns_no_changes_when_no_files_changed() {
300 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
301 let git_provider = MockGitProvider::new();
302 let changeset_reader = MockChangesetReader::new();
303
304 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
305
306 let input = VerifyInputBuilder::default()
307 .base("main".to_string())
308 .build()
309 .expect("all fields have defaults");
310
311 let result = operation
312 .execute(Path::new("/any"), &input)
313 .expect("VerifyOperation failed when no files changed");
314
315 assert!(matches!(result.outcome(), VerifyOutcome::NoChanges));
316 }
317
318 #[test]
319 fn returns_success_when_changeset_covers_affected_package() {
320 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
321
322 let git_provider = MockGitProvider::new().with_changed_files(vec![
323 FileChange::new(
324 PathBuf::from(".changeset/changesets/test.md"),
325 FileStatus::Added,
326 ),
327 FileChange::new(PathBuf::from("src/lib.rs"), FileStatus::Modified),
328 ]);
329
330 let changeset = crate::mocks::make_changeset("my-crate", BumpType::Patch, "Fix bug");
331 let changeset_reader = MockChangesetReader::new()
332 .with_changeset(PathBuf::from(".changeset/changesets/test.md"), changeset);
333
334 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
335
336 let input = VerifyInputBuilder::default()
337 .base("main".to_string())
338 .build()
339 .expect("all fields have defaults");
340
341 let result = operation
342 .execute(Path::new("/any"), &input)
343 .expect("VerifyOperation failed when changeset covers affected package");
344
345 match result.outcome() {
346 VerifyOutcome::Success(verification_result) => {
347 assert!(verification_result.uncovered_packages().is_empty());
348 assert!(verification_result.covered_packages().contains("my-crate"));
349 }
350 other => panic!("Expected VerifyOutcome::Success, got {other:?}"),
351 }
352 }
353
354 #[test]
355 fn returns_failed_when_package_not_covered() {
356 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
357
358 let git_provider = MockGitProvider::new().with_changed_files(vec![FileChange::new(
359 PathBuf::from("src/lib.rs"),
360 FileStatus::Modified,
361 )]);
362
363 let changeset_reader = MockChangesetReader::new();
364
365 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
366
367 let input = VerifyInputBuilder::default()
368 .base("main".to_string())
369 .build()
370 .expect("all fields have defaults");
371
372 let result = operation
373 .execute(Path::new("/any"), &input)
374 .expect("VerifyOperation failed unexpectedly when package not covered");
375
376 match result.outcome() {
377 VerifyOutcome::Failed(verification_result) => {
378 assert!(!verification_result.uncovered_packages().is_empty());
379 }
380 other => panic!("Expected VerifyOutcome::Failed, got {other:?}"),
381 }
382 }
383
384 #[test]
385 fn extract_deleted_changesets_identifies_deleted_md_files() {
386 let changes = vec![
387 FileChange::new(
388 PathBuf::from(".changeset/changesets/old.md"),
389 FileStatus::Deleted,
390 ),
391 FileChange::new(PathBuf::from("src/main.rs"), FileStatus::Deleted),
392 ];
393
394 let deleted = extract_deleted_changesets(&changes, Path::new(".changeset"));
395
396 assert_eq!(deleted.len(), 1);
397 assert_eq!(deleted[0], PathBuf::from(".changeset/changesets/old.md"));
398 }
399
400 #[test]
401 fn extract_active_changesets_identifies_added_and_modified() {
402 let changes = vec![
403 FileChange::new(
404 PathBuf::from(".changeset/changesets/new.md"),
405 FileStatus::Added,
406 ),
407 FileChange::new(
408 PathBuf::from(".changeset/changesets/updated.md"),
409 FileStatus::Modified,
410 ),
411 FileChange::new(
412 PathBuf::from(".changeset/changesets/deleted.md"),
413 FileStatus::Deleted,
414 ),
415 ];
416
417 let active = extract_active_changesets(&changes);
418
419 assert_eq!(active.len(), 2);
420 assert!(active.contains(&PathBuf::from(".changeset/changesets/new.md")));
421 assert!(active.contains(&PathBuf::from(".changeset/changesets/updated.md")));
422 }
423
424 #[test]
425 fn returns_success_when_changeset_has_none_bump_type() {
426 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
427
428 let git_provider = MockGitProvider::new().with_changed_files(vec![
429 FileChange::new(
430 PathBuf::from(".changeset/changesets/internal.md"),
431 FileStatus::Added,
432 ),
433 FileChange::new(PathBuf::from("src/lib.rs"), FileStatus::Modified),
434 ]);
435
436 let changeset =
437 crate::mocks::make_changeset("my-crate", BumpType::None, "Internal refactoring");
438 let changeset_reader = MockChangesetReader::new().with_changeset(
439 PathBuf::from(".changeset/changesets/internal.md"),
440 changeset,
441 );
442
443 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
444
445 let input = VerifyInputBuilder::default()
446 .base("main".to_string())
447 .build()
448 .expect("all fields have defaults");
449
450 let result = operation
451 .execute(Path::new("/any"), &input)
452 .expect("VerifyOperation failed when changeset has None bump type");
453
454 match result.outcome() {
455 VerifyOutcome::Success(verification_result) => {
456 assert!(verification_result.uncovered_packages().is_empty());
457 assert!(verification_result.covered_packages().contains("my-crate"));
458 }
459 other => panic!("Expected VerifyOutcome::Success, got {other:?}"),
460 }
461 }
462
463 #[test]
464 fn is_markdown_file_recognizes_md_extension() {
465 assert!(is_markdown_file(Path::new("test.md")));
466 assert!(is_markdown_file(Path::new("path/to/file.md")));
467 assert!(!is_markdown_file(Path::new("test.rs")));
468 assert!(!is_markdown_file(Path::new("test")));
469 }
470
471 #[test]
472 fn fails_when_transitive_dependent_not_covered() {
473 let project_provider =
474 MockProjectProvider::workspace(vec![("core", "1.0.0"), ("app", "1.0.0")])
475 .with_dependency_edges(vec![("app", "core")]);
476
477 let git_provider = MockGitProvider::new().with_changed_files(vec![
478 FileChange::new(
479 PathBuf::from(".changeset/changesets/fix.md"),
480 FileStatus::Added,
481 ),
482 FileChange::new(
483 PathBuf::from("crates/core/src/lib.rs"),
484 FileStatus::Modified,
485 ),
486 ]);
487
488 let changeset = crate::mocks::make_changeset("core", BumpType::Patch, "Fix core bug");
489 let changeset_reader = MockChangesetReader::new()
490 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
491
492 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
493
494 let input = VerifyInputBuilder::default()
495 .base("main".to_string())
496 .build()
497 .expect("all fields have defaults");
498
499 let result = operation
500 .execute(Path::new("/any"), &input)
501 .expect("operation should not error");
502
503 match result.outcome() {
504 VerifyOutcome::Failed(verification_result) => {
505 assert!(
506 verification_result
507 .uncovered_packages()
508 .iter()
509 .any(|p| p.name() == "app"),
510 "app should be uncovered as a transitive dependent of core"
511 );
512 }
513 other => panic!("Expected VerifyOutcome::Failed, got {other:?}"),
514 }
515 }
516
517 #[test]
518 fn succeeds_when_transitive_dependent_is_covered() {
519 let project_provider =
520 MockProjectProvider::workspace(vec![("core", "1.0.0"), ("app", "1.0.0")])
521 .with_dependency_edges(vec![("app", "core")]);
522
523 let git_provider = MockGitProvider::new().with_changed_files(vec![
524 FileChange::new(
525 PathBuf::from(".changeset/changesets/fix.md"),
526 FileStatus::Added,
527 ),
528 FileChange::new(
529 PathBuf::from("crates/core/src/lib.rs"),
530 FileStatus::Modified,
531 ),
532 ]);
533
534 let changeset = changeset_core::Changeset::new(
535 "Fix core bug".to_string(),
536 vec![
537 changeset_core::PackageRelease::new("core".to_string(), BumpType::Patch),
538 changeset_core::PackageRelease::new("app".to_string(), BumpType::Patch),
539 ],
540 changeset_core::ChangeCategory::Changed,
541 );
542 let changeset_reader = MockChangesetReader::new()
543 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
544
545 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
546
547 let input = VerifyInputBuilder::default()
548 .base("main".to_string())
549 .build()
550 .expect("all fields have defaults");
551
552 let result = operation
553 .execute(Path::new("/any"), &input)
554 .expect("operation should not error");
555
556 match result.outcome() {
557 VerifyOutcome::Success(verification_result) => {
558 assert!(verification_result.covered_packages().contains("core"));
559 assert!(verification_result.covered_packages().contains("app"));
560 assert!(verification_result.uncovered_packages().is_empty());
561 }
562 other => panic!("Expected VerifyOutcome::Success, got {other:?}"),
563 }
564 }
565
566 #[test]
567 fn exclude_dependents_skips_transitive_expansion() {
568 let project_provider =
569 MockProjectProvider::workspace(vec![("core", "1.0.0"), ("app", "1.0.0")])
570 .with_dependency_edges(vec![("app", "core")]);
571
572 let git_provider = MockGitProvider::new().with_changed_files(vec![
573 FileChange::new(
574 PathBuf::from(".changeset/changesets/fix.md"),
575 FileStatus::Added,
576 ),
577 FileChange::new(
578 PathBuf::from("crates/core/src/lib.rs"),
579 FileStatus::Modified,
580 ),
581 ]);
582
583 let changeset = crate::mocks::make_changeset("core", BumpType::Patch, "Fix core bug");
584 let changeset_reader = MockChangesetReader::new()
585 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
586
587 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
588
589 let input = VerifyInputBuilder::default()
590 .base("main".to_string())
591 .exclude_dependents(true)
592 .build()
593 .expect("all fields have defaults");
594
595 let result = operation
596 .execute(Path::new("/any"), &input)
597 .expect("operation should not error");
598
599 match result.outcome() {
600 VerifyOutcome::Success(verification_result) => {
601 assert!(verification_result.covered_packages().contains("core"));
602 assert!(verification_result.uncovered_packages().is_empty());
603 }
604 other => panic!("Expected VerifyOutcome::Success, got {other:?}"),
605 }
606 }
607
608 #[test]
609 fn single_package_skips_dependency_computation() {
610 let project_provider = MockProjectProvider::single_package("solo", "1.0.0");
611
612 let git_provider = MockGitProvider::new().with_changed_files(vec![
613 FileChange::new(
614 PathBuf::from(".changeset/changesets/fix.md"),
615 FileStatus::Added,
616 ),
617 FileChange::new(PathBuf::from("src/lib.rs"), FileStatus::Modified),
618 ]);
619
620 let changeset = crate::mocks::make_changeset("solo", BumpType::Patch, "Fix bug");
621 let changeset_reader = MockChangesetReader::new()
622 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
623
624 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
625
626 let input = VerifyInputBuilder::default()
627 .base("main".to_string())
628 .build()
629 .expect("all fields have defaults");
630
631 let result = operation
632 .execute(Path::new("/any"), &input)
633 .expect("operation should not error");
634
635 match result.outcome() {
636 VerifyOutcome::Success(verification_result) => {
637 assert!(verification_result.covered_packages().contains("solo"));
638 assert!(verification_result.uncovered_packages().is_empty());
639 }
640 other => panic!("Expected VerifyOutcome::Success, got {other:?}"),
641 }
642 }
643
644 #[test]
645 fn dirty_tree_uses_uncommitted_changes() {
646 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
647
648 let uncommitted = vec![
649 FileChange::new(
650 PathBuf::from(".changeset/changesets/local.md"),
651 FileStatus::Added,
652 ),
653 FileChange::new(PathBuf::from("src/lib.rs"), FileStatus::Modified),
654 ];
655
656 let git_provider = MockGitProvider::new()
657 .is_clean(false)
658 .with_uncommitted_changes(uncommitted);
659
660 let changeset = crate::mocks::make_changeset("my-crate", BumpType::Patch, "Local fix");
661 let changeset_reader = MockChangesetReader::new()
662 .with_changeset(PathBuf::from(".changeset/changesets/local.md"), changeset);
663
664 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
665
666 let input = VerifyInputBuilder::default()
667 .base("main".to_string())
668 .build()
669 .expect("all fields have defaults");
670
671 let result = operation
672 .execute(Path::new("/any"), &input)
673 .expect("operation should not error");
674
675 assert!(result.is_dirty());
676 match result.outcome() {
677 VerifyOutcome::Success(verification_result) => {
678 assert!(verification_result.covered_packages().contains("my-crate"));
679 }
680 other => panic!("Expected VerifyOutcome::Success, got {other:?}"),
681 }
682 }
683
684 #[test]
685 fn clean_tree_uses_branch_diff() {
686 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
687
688 let git_provider = MockGitProvider::new()
689 .is_clean(true)
690 .with_changed_files(vec![
691 FileChange::new(
692 PathBuf::from(".changeset/changesets/test.md"),
693 FileStatus::Added,
694 ),
695 FileChange::new(PathBuf::from("src/lib.rs"), FileStatus::Modified),
696 ]);
697
698 let changeset = crate::mocks::make_changeset("my-crate", BumpType::Patch, "Fix bug");
699 let changeset_reader = MockChangesetReader::new()
700 .with_changeset(PathBuf::from(".changeset/changesets/test.md"), changeset);
701
702 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
703
704 let input = VerifyInputBuilder::default()
705 .base("main".to_string())
706 .build()
707 .expect("all fields have defaults");
708
709 let result = operation
710 .execute(Path::new("/any"), &input)
711 .expect("operation should not error");
712
713 assert!(!result.is_dirty());
714 match result.outcome() {
715 VerifyOutcome::Success(verification_result) => {
716 assert!(verification_result.covered_packages().contains("my-crate"));
717 }
718 other => panic!("Expected VerifyOutcome::Success, got {other:?}"),
719 }
720 }
721
722 #[test]
723 fn dirty_tree_with_empty_uncommitted_changes_yields_no_changes() {
724 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
725
726 let git_provider = MockGitProvider::new()
727 .is_clean(false)
728 .with_uncommitted_changes(vec![]);
729
730 let changeset_reader = MockChangesetReader::new();
731
732 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
733
734 let input = VerifyInputBuilder::default()
735 .base("main".to_string())
736 .build()
737 .expect("all fields have defaults");
738
739 let result = operation
740 .execute(Path::new("/any"), &input)
741 .expect("operation should not error");
742
743 assert!(!result.is_dirty());
744 assert!(matches!(result.outcome(), VerifyOutcome::NoChanges));
745 }
746
747 #[test]
748 fn dirty_tree_with_only_changeset_file_changes_reports_no_code_changes() {
749 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
750
751 let git_provider = MockGitProvider::new()
752 .is_clean(false)
753 .with_uncommitted_changes(vec![FileChange::new(
754 PathBuf::from(".changeset/changesets/local.md"),
755 FileStatus::Added,
756 )]);
757
758 let changeset_reader = MockChangesetReader::new();
759
760 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
761
762 let input = VerifyInputBuilder::default()
763 .base("main".to_string())
764 .build()
765 .expect("all fields have defaults");
766
767 let result = operation
768 .execute(Path::new("/any"), &input)
769 .expect("operation should not error");
770
771 assert!(result.is_dirty());
772 assert!(matches!(result.outcome(), VerifyOutcome::NoChanges));
773 }
774
775 #[test]
776 fn is_working_tree_clean_error_propagates() {
777 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
778
779 let git_provider = Arc::new(MockGitProvider::new());
780 git_provider.set_fail_on_is_clean(true);
781
782 let changeset_reader = MockChangesetReader::new();
783
784 let operation = VerifyOperation::new(
785 project_provider,
786 Arc::clone(&git_provider),
787 changeset_reader,
788 );
789
790 let input = VerifyInputBuilder::default()
791 .base("main".to_string())
792 .build()
793 .expect("all fields have defaults");
794
795 let result = operation.execute(Path::new("/any"), &input);
796
797 assert!(result.is_err());
798 }
799
800 #[test]
801 fn dirty_tree_fails_when_package_not_covered() {
802 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
803
804 let git_provider = MockGitProvider::new()
805 .is_clean(false)
806 .with_uncommitted_changes(vec![FileChange::new(
807 PathBuf::from("src/lib.rs"),
808 FileStatus::Modified,
809 )]);
810
811 let changeset_reader = MockChangesetReader::new();
812
813 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
814
815 let input = VerifyInputBuilder::default()
816 .base("main".to_string())
817 .build()
818 .expect("all fields have defaults");
819
820 let result = operation
821 .execute(Path::new("/any"), &input)
822 .expect("operation should not error");
823
824 assert!(result.is_dirty());
825 match result.outcome() {
826 VerifyOutcome::Failed(verification_result) => {
827 assert!(!verification_result.uncovered_packages().is_empty());
828 }
829 other => panic!("Expected VerifyOutcome::Failed, got {other:?}"),
830 }
831 }
832
833 #[test]
834 fn dirty_tree_fails_when_transitive_dependent_not_covered() {
835 let project_provider =
836 MockProjectProvider::workspace(vec![("core", "1.0.0"), ("app", "1.0.0")])
837 .with_dependency_edges(vec![("app", "core")]);
838
839 let git_provider = MockGitProvider::new()
840 .is_clean(false)
841 .with_uncommitted_changes(vec![
842 FileChange::new(
843 PathBuf::from(".changeset/changesets/fix.md"),
844 FileStatus::Added,
845 ),
846 FileChange::new(
847 PathBuf::from("crates/core/src/lib.rs"),
848 FileStatus::Modified,
849 ),
850 ]);
851
852 let changeset = crate::mocks::make_changeset("core", BumpType::Patch, "Fix core bug");
853 let changeset_reader = MockChangesetReader::new()
854 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
855
856 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
857
858 let input = VerifyInputBuilder::default()
859 .base("main".to_string())
860 .build()
861 .expect("all fields have defaults");
862
863 let result = operation
864 .execute(Path::new("/any"), &input)
865 .expect("operation should not error");
866
867 assert!(result.is_dirty());
868 match result.outcome() {
869 VerifyOutcome::Failed(verification_result) => {
870 assert!(
871 verification_result
872 .uncovered_packages()
873 .iter()
874 .any(|p| p.name() == "app"),
875 "app should be uncovered as a transitive dependent of core"
876 );
877 }
878 other => panic!("Expected VerifyOutcome::Failed, got {other:?}"),
879 }
880 }
881
882 #[test]
883 fn ignore_dirty_skips_dirty_check() {
884 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
885
886 let git_provider = Arc::new(
887 MockGitProvider::new()
888 .with_changed_files(vec![
889 FileChange::new(
890 PathBuf::from(".changeset/changesets/test.md"),
891 FileStatus::Added,
892 ),
893 FileChange::new(PathBuf::from("src/lib.rs"), FileStatus::Modified),
894 ])
895 .with_uncommitted_changes(vec![]),
896 );
897 git_provider.set_fail_on_is_clean(true);
898
899 let changeset = crate::mocks::make_changeset("my-crate", BumpType::Patch, "Fix bug");
900 let changeset_reader = MockChangesetReader::new()
901 .with_changeset(PathBuf::from(".changeset/changesets/test.md"), changeset);
902
903 let operation = VerifyOperation::new(
904 project_provider,
905 Arc::clone(&git_provider),
906 changeset_reader,
907 );
908
909 let input = VerifyInputBuilder::default()
910 .base("main".to_string())
911 .ignore_dirty(true)
912 .build()
913 .expect("all fields have defaults");
914
915 let result = operation
916 .execute(Path::new("/any"), &input)
917 .expect("operation should not error");
918
919 assert!(!result.is_dirty());
920 match result.outcome() {
921 VerifyOutcome::Success(verification_result) => {
922 assert!(verification_result.covered_packages().contains("my-crate"));
923 }
924 other => panic!("Expected VerifyOutcome::Success, got {other:?}"),
925 }
926 }
927
928 #[test]
929 fn disallow_rejects_none_bump_in_verify() {
930 let root_config =
931 RootChangesetConfig::default().with_none_bump_behavior(NoneBumpBehavior::Disallow);
932 let project_provider =
933 MockProjectProvider::single_package("my-crate", "1.0.0").with_root_config(root_config);
934
935 let git_provider = MockGitProvider::new().with_changed_files(vec![
936 FileChange::new(
937 PathBuf::from(".changeset/changesets/internal.md"),
938 FileStatus::Added,
939 ),
940 FileChange::new(PathBuf::from("src/lib.rs"), FileStatus::Modified),
941 ]);
942
943 let changeset =
944 crate::mocks::make_changeset("my-crate", BumpType::None, "Internal refactoring");
945 let changeset_reader = MockChangesetReader::new().with_changeset(
946 PathBuf::from(".changeset/changesets/internal.md"),
947 changeset,
948 );
949
950 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
951
952 let input = VerifyInputBuilder::default()
953 .base("main".to_string())
954 .build()
955 .expect("all fields have defaults");
956
957 let result = operation
958 .execute(Path::new("/any"), &input)
959 .expect("operation should not error");
960
961 match result.outcome() {
962 VerifyOutcome::Failed(verification_result) => {
963 assert!(
964 verification_result
965 .none_bump_violations()
966 .contains(&"my-crate".to_string()),
967 "my-crate should be in none_bump_violations"
968 );
969 }
970 other => panic!("Expected VerifyOutcome::Failed, got {other:?}"),
971 }
972 }
973
974 #[test]
975 fn allow_permits_none_bump_in_verify() {
976 let root_config =
977 RootChangesetConfig::default().with_none_bump_behavior(NoneBumpBehavior::Allow);
978 let project_provider =
979 MockProjectProvider::single_package("my-crate", "1.0.0").with_root_config(root_config);
980
981 let git_provider = MockGitProvider::new().with_changed_files(vec![
982 FileChange::new(
983 PathBuf::from(".changeset/changesets/internal.md"),
984 FileStatus::Added,
985 ),
986 FileChange::new(PathBuf::from("src/lib.rs"), FileStatus::Modified),
987 ]);
988
989 let changeset =
990 crate::mocks::make_changeset("my-crate", BumpType::None, "Internal refactoring");
991 let changeset_reader = MockChangesetReader::new().with_changeset(
992 PathBuf::from(".changeset/changesets/internal.md"),
993 changeset,
994 );
995
996 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
997
998 let input = VerifyInputBuilder::default()
999 .base("main".to_string())
1000 .build()
1001 .expect("all fields have defaults");
1002
1003 let result = operation
1004 .execute(Path::new("/any"), &input)
1005 .expect("operation should not error");
1006
1007 match result.outcome() {
1008 VerifyOutcome::Success(verification_result) => {
1009 assert!(verification_result.none_bump_violations().is_empty());
1010 assert!(verification_result.covered_packages().contains("my-crate"));
1011 }
1012 other => panic!("Expected VerifyOutcome::Success, got {other:?}"),
1013 }
1014 }
1015
1016 #[test]
1017 fn promote_to_patch_permits_none_bump_in_verify() {
1018 let root_config = RootChangesetConfig::default()
1019 .with_none_bump_behavior(NoneBumpBehavior::PromoteToPatch);
1020 let project_provider =
1021 MockProjectProvider::single_package("my-crate", "1.0.0").with_root_config(root_config);
1022
1023 let git_provider = MockGitProvider::new().with_changed_files(vec![
1024 FileChange::new(
1025 PathBuf::from(".changeset/changesets/internal.md"),
1026 FileStatus::Added,
1027 ),
1028 FileChange::new(PathBuf::from("src/lib.rs"), FileStatus::Modified),
1029 ]);
1030
1031 let changeset =
1032 crate::mocks::make_changeset("my-crate", BumpType::None, "Internal refactoring");
1033 let changeset_reader = MockChangesetReader::new().with_changeset(
1034 PathBuf::from(".changeset/changesets/internal.md"),
1035 changeset,
1036 );
1037
1038 let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
1039
1040 let input = VerifyInputBuilder::default()
1041 .base("main".to_string())
1042 .build()
1043 .expect("all fields have defaults");
1044
1045 let result = operation
1046 .execute(Path::new("/any"), &input)
1047 .expect("operation should not error");
1048
1049 match result.outcome() {
1050 VerifyOutcome::Success(verification_result) => {
1051 assert!(verification_result.none_bump_violations().is_empty());
1052 assert!(verification_result.covered_packages().contains("my-crate"));
1053 }
1054 other => panic!("Expected VerifyOutcome::Success, got {other:?}"),
1055 }
1056 }
1057}