changeset_operations/operations/
verify.rs1use 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 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}