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, 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 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}