Skip to main content

changeset_operations/operations/
verify.rs

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    /// # Errors
82    ///
83    /// Returns an error if project discovery, git operations, or changeset reads fail.
84    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}