Skip to main content

changeset_operations/operations/
verify.rs

1use std::path::{Path, PathBuf};
2
3use changeset_git::{FileChange, FileStatus};
4use changeset_project::map_files_to_packages;
5
6use crate::Result;
7use crate::traits::{ChangesetReader, GitDiffProvider, ProjectProvider};
8use crate::verification::rules::{CoverageRule, DeletedChangesetsRule};
9use crate::verification::{VerificationContext, VerificationEngine, VerificationResult};
10
11pub struct VerifyInput {
12    pub base: String,
13    pub head: Option<String>,
14    pub allow_deleted_changesets: bool,
15}
16
17#[derive(Debug)]
18pub enum VerifyOutcome {
19    Success(VerificationResult),
20    NoChanges,
21    NoPackagesAffected {
22        project_file_count: usize,
23        ignored_file_count: usize,
24    },
25    Failed(VerificationResult),
26}
27
28pub struct VerifyOperation<P, G, R> {
29    project_provider: P,
30    git_provider: G,
31    changeset_reader: R,
32}
33
34impl<P, G, R> VerifyOperation<P, G, R>
35where
36    P: ProjectProvider,
37    G: GitDiffProvider,
38    R: ChangesetReader,
39{
40    pub fn new(project_provider: P, git_provider: G, changeset_reader: R) -> Self {
41        Self {
42            project_provider,
43            git_provider,
44            changeset_reader,
45        }
46    }
47
48    /// # Errors
49    ///
50    /// Returns an error if the project cannot be discovered, git operations fail,
51    /// or changeset files cannot be read.
52    pub fn execute(&self, start_path: &Path, input: &VerifyInput) -> Result<VerifyOutcome> {
53        let project = self.project_provider.discover_project(start_path)?;
54        let (root_config, package_configs) = self.project_provider.load_configs(&project)?;
55        let changeset_dir = root_config.changeset_dir();
56
57        let head_ref = input.head.as_deref().unwrap_or("HEAD");
58        let changed_files =
59            self.git_provider
60                .changed_files(project.root(), &input.base, head_ref)?;
61
62        let (changeset_changes, code_changes): (Vec<_>, Vec<_>) = changed_files
63            .into_iter()
64            .partition(|change| change.path.starts_with(changeset_dir));
65
66        let deleted_changesets = extract_deleted_changesets(&changeset_changes, changeset_dir);
67        let changeset_files = extract_active_changesets(&changeset_changes);
68
69        let changed_paths: Vec<PathBuf> =
70            code_changes.into_iter().map(|change| change.path).collect();
71
72        let has_deleted_changesets = !deleted_changesets.is_empty();
73        let has_code_changes = !changed_paths.is_empty();
74
75        if !has_code_changes && !has_deleted_changesets {
76            return Ok(VerifyOutcome::NoChanges);
77        }
78
79        let mapping = if has_code_changes {
80            Some(map_files_to_packages(
81                &project,
82                &changed_paths,
83                &root_config,
84                &package_configs,
85            ))
86        } else {
87            None
88        };
89
90        let affected_packages = mapping.as_ref().map_or(
91            Vec::new(),
92            changeset_project::FileMapping::affected_packages,
93        );
94
95        if affected_packages.is_empty() && !has_deleted_changesets {
96            let (project_file_count, ignored_file_count) = mapping
97                .as_ref()
98                .map_or((0, 0), |m| (m.project_files.len(), m.ignored_files.len()));
99            return Ok(VerifyOutcome::NoPackagesAffected {
100                project_file_count,
101                ignored_file_count,
102            });
103        }
104
105        let context = build_context(mapping.as_ref(), changeset_files, deleted_changesets);
106
107        let deleted_rule = DeletedChangesetsRule::new(input.allow_deleted_changesets);
108        let coverage_rule = CoverageRule::new(&self.changeset_reader);
109
110        let mut engine = VerificationEngine::new();
111        engine.add_rule(&deleted_rule);
112        engine.add_rule(&coverage_rule);
113
114        let result = engine.verify(&context)?;
115
116        if result.is_success() {
117            Ok(VerifyOutcome::Success(result))
118        } else {
119            Ok(VerifyOutcome::Failed(result))
120        }
121    }
122}
123
124fn is_markdown_file(path: &Path) -> bool {
125    path.extension().is_some_and(|ext| ext == "md")
126}
127
128fn extract_deleted_changesets(changes: &[FileChange], changeset_dir: &Path) -> Vec<PathBuf> {
129    changes
130        .iter()
131        .filter_map(|change| match change.status {
132            FileStatus::Deleted if is_markdown_file(&change.path) => Some(change.path.clone()),
133            FileStatus::Renamed => change
134                .old_path
135                .as_ref()
136                .filter(|old| old.starts_with(changeset_dir) && is_markdown_file(old))
137                .cloned(),
138            _ => None,
139        })
140        .collect()
141}
142
143fn extract_active_changesets(changes: &[FileChange]) -> Vec<PathBuf> {
144    changes
145        .iter()
146        .filter(|change| {
147            is_markdown_file(&change.path)
148                && matches!(
149                    change.status,
150                    FileStatus::Added
151                        | FileStatus::Modified
152                        | FileStatus::Renamed
153                        | FileStatus::Typechange
154                )
155        })
156        .map(|change| change.path.clone())
157        .collect()
158}
159
160fn build_context(
161    mapping: Option<&changeset_project::FileMapping>,
162    changeset_files: Vec<PathBuf>,
163    deleted_changesets: Vec<PathBuf>,
164) -> VerificationContext {
165    match mapping {
166        Some(m) => VerificationContext {
167            affected_packages: m.affected_packages().into_iter().cloned().collect(),
168            changeset_files,
169            deleted_changesets,
170            project_files: m.project_files.clone(),
171            ignored_files: m.ignored_files.clone(),
172        },
173        None => VerificationContext {
174            affected_packages: Vec::new(),
175            changeset_files,
176            deleted_changesets,
177            project_files: Vec::new(),
178            ignored_files: Vec::new(),
179        },
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::mocks::{MockChangesetReader, MockGitProvider, MockProjectProvider};
187    use changeset_core::BumpType;
188    use changeset_git::FileStatus;
189
190    #[test]
191    fn returns_no_changes_when_no_files_changed() {
192        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
193        let git_provider = MockGitProvider::new();
194        let changeset_reader = MockChangesetReader::new();
195
196        let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
197
198        let input = VerifyInput {
199            base: "main".to_string(),
200            head: None,
201            allow_deleted_changesets: false,
202        };
203
204        let result = operation
205            .execute(Path::new("/any"), &input)
206            .expect("VerifyOperation failed when no files changed");
207
208        assert!(matches!(result, VerifyOutcome::NoChanges));
209    }
210
211    #[test]
212    fn returns_success_when_changeset_covers_affected_package() {
213        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
214
215        let git_provider = MockGitProvider::new().with_changed_files(vec![
216            FileChange {
217                path: PathBuf::from(".changeset/changesets/test.md"),
218                status: FileStatus::Added,
219                old_path: None,
220            },
221            FileChange {
222                path: PathBuf::from("src/lib.rs"),
223                status: FileStatus::Modified,
224                old_path: None,
225            },
226        ]);
227
228        let changeset = crate::mocks::make_changeset("my-crate", BumpType::Patch, "Fix bug");
229        let changeset_reader = MockChangesetReader::new()
230            .with_changeset(PathBuf::from(".changeset/changesets/test.md"), changeset);
231
232        let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
233
234        let input = VerifyInput {
235            base: "main".to_string(),
236            head: None,
237            allow_deleted_changesets: false,
238        };
239
240        let result = operation
241            .execute(Path::new("/any"), &input)
242            .expect("VerifyOperation failed when changeset covers affected package");
243
244        match result {
245            VerifyOutcome::Success(verification_result) => {
246                assert!(verification_result.uncovered_packages.is_empty());
247                assert!(verification_result.covered_packages.contains("my-crate"));
248            }
249            other => panic!("Expected VerifyOutcome::Success, got {other:?}"),
250        }
251    }
252
253    #[test]
254    fn returns_failed_when_package_not_covered() {
255        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
256
257        let git_provider = MockGitProvider::new().with_changed_files(vec![FileChange {
258            path: PathBuf::from("src/lib.rs"),
259            status: FileStatus::Modified,
260            old_path: None,
261        }]);
262
263        let changeset_reader = MockChangesetReader::new();
264
265        let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
266
267        let input = VerifyInput {
268            base: "main".to_string(),
269            head: None,
270            allow_deleted_changesets: false,
271        };
272
273        let result = operation
274            .execute(Path::new("/any"), &input)
275            .expect("VerifyOperation failed unexpectedly when package not covered");
276
277        match result {
278            VerifyOutcome::Failed(verification_result) => {
279                assert!(!verification_result.uncovered_packages.is_empty());
280            }
281            other => panic!("Expected VerifyOutcome::Failed, got {other:?}"),
282        }
283    }
284
285    #[test]
286    fn extract_deleted_changesets_identifies_deleted_md_files() {
287        let changes = vec![
288            FileChange {
289                path: PathBuf::from(".changeset/changesets/old.md"),
290                status: FileStatus::Deleted,
291                old_path: None,
292            },
293            FileChange {
294                path: PathBuf::from("src/main.rs"),
295                status: FileStatus::Deleted,
296                old_path: None,
297            },
298        ];
299
300        let deleted = extract_deleted_changesets(&changes, Path::new(".changeset"));
301
302        assert_eq!(deleted.len(), 1);
303        assert_eq!(deleted[0], PathBuf::from(".changeset/changesets/old.md"));
304    }
305
306    #[test]
307    fn extract_active_changesets_identifies_added_and_modified() {
308        let changes = vec![
309            FileChange {
310                path: PathBuf::from(".changeset/changesets/new.md"),
311                status: FileStatus::Added,
312                old_path: None,
313            },
314            FileChange {
315                path: PathBuf::from(".changeset/changesets/updated.md"),
316                status: FileStatus::Modified,
317                old_path: None,
318            },
319            FileChange {
320                path: PathBuf::from(".changeset/changesets/deleted.md"),
321                status: FileStatus::Deleted,
322                old_path: None,
323            },
324        ];
325
326        let active = extract_active_changesets(&changes);
327
328        assert_eq!(active.len(), 2);
329        assert!(active.contains(&PathBuf::from(".changeset/changesets/new.md")));
330        assert!(active.contains(&PathBuf::from(".changeset/changesets/updated.md")));
331    }
332
333    #[test]
334    fn is_markdown_file_recognizes_md_extension() {
335        assert!(is_markdown_file(Path::new("test.md")));
336        assert!(is_markdown_file(Path::new("path/to/file.md")));
337        assert!(!is_markdown_file(Path::new("test.rs")));
338        assert!(!is_markdown_file(Path::new("test")));
339    }
340}