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, GitProvider, 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: GitProvider,
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 | FileStatus::Modified | FileStatus::Renamed
151                )
152        })
153        .map(|change| change.path.clone())
154        .collect()
155}
156
157fn build_context(
158    mapping: Option<&changeset_project::FileMapping>,
159    changeset_files: Vec<PathBuf>,
160    deleted_changesets: Vec<PathBuf>,
161) -> VerificationContext {
162    match mapping {
163        Some(m) => VerificationContext {
164            affected_packages: m.affected_packages().into_iter().cloned().collect(),
165            changeset_files,
166            deleted_changesets,
167            project_files: m.project_files.clone(),
168            ignored_files: m.ignored_files.clone(),
169        },
170        None => VerificationContext {
171            affected_packages: Vec::new(),
172            changeset_files,
173            deleted_changesets,
174            project_files: Vec::new(),
175            ignored_files: Vec::new(),
176        },
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::mocks::{MockChangesetReader, MockGitProvider, MockProjectProvider};
184    use changeset_core::BumpType;
185    use changeset_git::FileStatus;
186
187    #[test]
188    fn returns_no_changes_when_no_files_changed() {
189        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
190        let git_provider = MockGitProvider::new();
191        let changeset_reader = MockChangesetReader::new();
192
193        let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
194
195        let input = VerifyInput {
196            base: "main".to_string(),
197            head: None,
198            allow_deleted_changesets: false,
199        };
200
201        let result = operation
202            .execute(Path::new("/any"), &input)
203            .expect("VerifyOperation failed when no files changed");
204
205        assert!(matches!(result, VerifyOutcome::NoChanges));
206    }
207
208    #[test]
209    fn returns_success_when_changeset_covers_affected_package() {
210        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
211
212        let git_provider = MockGitProvider::new().with_changed_files(vec![
213            FileChange {
214                path: PathBuf::from(".changeset/changesets/test.md"),
215                status: FileStatus::Added,
216                old_path: None,
217            },
218            FileChange {
219                path: PathBuf::from("src/lib.rs"),
220                status: FileStatus::Modified,
221                old_path: None,
222            },
223        ]);
224
225        let changeset = crate::mocks::make_changeset("my-crate", BumpType::Patch, "Fix bug");
226        let changeset_reader = MockChangesetReader::new()
227            .with_changeset(PathBuf::from(".changeset/changesets/test.md"), changeset);
228
229        let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
230
231        let input = VerifyInput {
232            base: "main".to_string(),
233            head: None,
234            allow_deleted_changesets: false,
235        };
236
237        let result = operation
238            .execute(Path::new("/any"), &input)
239            .expect("VerifyOperation failed when changeset covers affected package");
240
241        match result {
242            VerifyOutcome::Success(verification_result) => {
243                assert!(verification_result.uncovered_packages.is_empty());
244                assert!(verification_result.covered_packages.contains("my-crate"));
245            }
246            other => panic!("Expected VerifyOutcome::Success, got {other:?}"),
247        }
248    }
249
250    #[test]
251    fn returns_failed_when_package_not_covered() {
252        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
253
254        let git_provider = MockGitProvider::new().with_changed_files(vec![FileChange {
255            path: PathBuf::from("src/lib.rs"),
256            status: FileStatus::Modified,
257            old_path: None,
258        }]);
259
260        let changeset_reader = MockChangesetReader::new();
261
262        let operation = VerifyOperation::new(project_provider, git_provider, changeset_reader);
263
264        let input = VerifyInput {
265            base: "main".to_string(),
266            head: None,
267            allow_deleted_changesets: false,
268        };
269
270        let result = operation
271            .execute(Path::new("/any"), &input)
272            .expect("VerifyOperation failed unexpectedly when package not covered");
273
274        match result {
275            VerifyOutcome::Failed(verification_result) => {
276                assert!(!verification_result.uncovered_packages.is_empty());
277            }
278            other => panic!("Expected VerifyOutcome::Failed, got {other:?}"),
279        }
280    }
281
282    #[test]
283    fn extract_deleted_changesets_identifies_deleted_md_files() {
284        let changes = vec![
285            FileChange {
286                path: PathBuf::from(".changeset/changesets/old.md"),
287                status: FileStatus::Deleted,
288                old_path: None,
289            },
290            FileChange {
291                path: PathBuf::from("src/main.rs"),
292                status: FileStatus::Deleted,
293                old_path: None,
294            },
295        ];
296
297        let deleted = extract_deleted_changesets(&changes, Path::new(".changeset"));
298
299        assert_eq!(deleted.len(), 1);
300        assert_eq!(deleted[0], PathBuf::from(".changeset/changesets/old.md"));
301    }
302
303    #[test]
304    fn extract_active_changesets_identifies_added_and_modified() {
305        let changes = vec![
306            FileChange {
307                path: PathBuf::from(".changeset/changesets/new.md"),
308                status: FileStatus::Added,
309                old_path: None,
310            },
311            FileChange {
312                path: PathBuf::from(".changeset/changesets/updated.md"),
313                status: FileStatus::Modified,
314                old_path: None,
315            },
316            FileChange {
317                path: PathBuf::from(".changeset/changesets/deleted.md"),
318                status: FileStatus::Deleted,
319                old_path: None,
320            },
321        ];
322
323        let active = extract_active_changesets(&changes);
324
325        assert_eq!(active.len(), 2);
326        assert!(active.contains(&PathBuf::from(".changeset/changesets/new.md")));
327        assert!(active.contains(&PathBuf::from(".changeset/changesets/updated.md")));
328    }
329
330    #[test]
331    fn is_markdown_file_recognizes_md_extension() {
332        assert!(is_markdown_file(Path::new("test.md")));
333        assert!(is_markdown_file(Path::new("path/to/file.md")));
334        assert!(!is_markdown_file(Path::new("test.rs")));
335        assert!(!is_markdown_file(Path::new("test")));
336    }
337}