1use crate::changeset::BumpType;
8use crate::conventional::ConventionalCommit;
9use crate::error::{Error, Result};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14#[derive(Debug, Clone)]
16pub struct PackageAffect<'a> {
17 pub commit: &'a ConventionalCommit,
19 pub changed_files: Vec<PathBuf>,
21}
22
23impl PackageAffect<'_> {
24 #[must_use]
26 pub fn bump_type(&self) -> BumpType {
27 self.commit.bump_type()
28 }
29}
30
31pub struct CommitAnalyzer<'a> {
36 root: &'a Path,
37 package_paths: HashMap<String, PathBuf>,
39}
40
41impl<'a> CommitAnalyzer<'a> {
42 #[must_use]
49 pub const fn new(root: &'a Path, package_paths: HashMap<String, PathBuf>) -> Self {
50 Self {
51 root,
52 package_paths,
53 }
54 }
55
56 pub fn analyze<'c>(
65 &self,
66 commits: &'c [ConventionalCommit],
67 ) -> Result<HashMap<String, Vec<PackageAffect<'c>>>> {
68 let mut package_commits: HashMap<String, Vec<PackageAffect<'c>>> = HashMap::new();
69
70 for commit in commits {
71 let changed_files = self.get_changed_files(&commit.hash)?;
72 let affected = self.map_files_to_packages(&changed_files);
73
74 for (pkg_name, pkg_files) in affected {
75 let affect = PackageAffect {
76 commit,
77 changed_files: pkg_files,
78 };
79 package_commits.entry(pkg_name).or_default().push(affect);
80 }
81 }
82
83 Ok(package_commits)
84 }
85
86 pub fn calculate_bumps(
94 &self,
95 commits: &[ConventionalCommit],
96 ) -> Result<HashMap<String, BumpType>> {
97 let package_affects = self.analyze(commits)?;
98
99 let mut bumps = HashMap::new();
100 for (pkg_name, affects) in package_affects {
101 let max_bump = affects
102 .iter()
103 .map(PackageAffect::bump_type)
104 .max()
105 .unwrap_or(BumpType::None);
106
107 if max_bump != BumpType::None {
108 bumps.insert(pkg_name, max_bump);
109 }
110 }
111
112 Ok(bumps)
113 }
114
115 fn get_changed_files(&self, commit_hash: &str) -> Result<Vec<PathBuf>> {
120 let output = Command::new("git")
126 .args([
127 "diff-tree",
128 "--no-commit-id",
129 "--name-only",
130 "-r",
131 "--root",
132 commit_hash,
133 ])
134 .current_dir(self.root)
135 .output()
136 .map_err(|e| Error::git(format!("Failed to run git diff-tree: {e}")))?;
137
138 if !output.status.success() {
139 let stderr = String::from_utf8_lossy(&output.stderr);
140 return Err(Error::git(format!(
141 "git diff-tree failed for {commit_hash}: {stderr}"
142 )));
143 }
144
145 let stdout = String::from_utf8_lossy(&output.stdout);
146 let files: Vec<PathBuf> = stdout
147 .lines()
148 .filter(|line| !line.is_empty())
149 .map(PathBuf::from)
150 .collect();
151
152 Ok(files)
153 }
154
155 fn map_files_to_packages(&self, files: &[PathBuf]) -> HashMap<String, Vec<PathBuf>> {
159 let mut package_files: HashMap<String, Vec<PathBuf>> = HashMap::new();
160
161 for file in files {
162 if let Some(pkg_name) = self.file_to_package(file) {
163 package_files
164 .entry(pkg_name)
165 .or_default()
166 .push(file.clone());
167 }
168 }
169
170 package_files
171 }
172
173 fn file_to_package(&self, file_path: &Path) -> Option<String> {
178 let mut best_match: Option<(&String, usize)> = None;
181
182 for (pkg_name, pkg_path) in &self.package_paths {
183 let relative_pkg_path = if pkg_path.is_absolute() {
185 pkg_path.strip_prefix(self.root).unwrap_or(pkg_path)
186 } else {
187 pkg_path.as_path()
188 };
189
190 if file_path.starts_with(relative_pkg_path) {
191 let path_len = relative_pkg_path.components().count();
192 if best_match
193 .as_ref()
194 .is_none_or(|(_, prev_len)| path_len > *prev_len)
195 {
196 best_match = Some((pkg_name, path_len));
197 }
198 }
199 }
200
201 best_match.map(|(name, _)| name.clone())
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use std::fs;
209 use std::process::Command;
210 use tempfile::TempDir;
211
212 fn create_test_workspace(temp: &TempDir) -> PathBuf {
213 let root = temp.path().to_path_buf();
214
215 fs::create_dir_all(root.join("crates/foo/src")).unwrap();
217 fs::create_dir_all(root.join("crates/bar/src")).unwrap();
218
219 let root_manifest = r#"[workspace]
221resolver = "2"
222members = ["crates/foo", "crates/bar"]
223
224[workspace.package]
225version = "1.0.0"
226"#;
227 fs::write(root.join("Cargo.toml"), root_manifest).unwrap();
228
229 fs::write(
231 root.join("crates/foo/Cargo.toml"),
232 "[package]\nname = \"foo\"\nversion.workspace = true\n",
233 )
234 .unwrap();
235 fs::write(
236 root.join("crates/bar/Cargo.toml"),
237 "[package]\nname = \"bar\"\nversion.workspace = true\n",
238 )
239 .unwrap();
240
241 fs::write(root.join("crates/foo/src/lib.rs"), "// foo lib").unwrap();
243 fs::write(root.join("crates/bar/src/lib.rs"), "// bar lib").unwrap();
244
245 root
246 }
247
248 fn init_git_repo(path: &Path) {
249 Command::new("git")
250 .args(["init", "--ref-format=files"])
251 .current_dir(path)
252 .output()
253 .unwrap();
254
255 Command::new("git")
256 .args(["config", "user.name", "Test User"])
257 .current_dir(path)
258 .output()
259 .unwrap();
260
261 Command::new("git")
262 .args(["config", "user.email", "test@example.com"])
263 .current_dir(path)
264 .output()
265 .unwrap();
266 }
267
268 fn create_commit(path: &Path, message: &str) -> String {
269 Command::new("git")
270 .args(["add", "."])
271 .current_dir(path)
272 .output()
273 .unwrap();
274
275 Command::new("git")
276 .args(["commit", "--no-gpg-sign", "-m", message])
277 .current_dir(path)
278 .output()
279 .unwrap();
280
281 let output = Command::new("git")
283 .args(["rev-parse", "HEAD"])
284 .current_dir(path)
285 .output()
286 .unwrap();
287
288 String::from_utf8_lossy(&output.stdout).trim().to_string()
289 }
290
291 #[test]
292 fn test_file_to_package() {
293 let temp = TempDir::new().unwrap();
294 let root = create_test_workspace(&temp);
295
296 let package_paths = HashMap::from([
297 ("foo".to_string(), PathBuf::from("crates/foo")),
298 ("bar".to_string(), PathBuf::from("crates/bar")),
299 ]);
300
301 let analyzer = CommitAnalyzer::new(&root, package_paths);
302
303 assert_eq!(
305 analyzer.file_to_package(Path::new("crates/foo/src/lib.rs")),
306 Some("foo".to_string())
307 );
308 assert_eq!(
309 analyzer.file_to_package(Path::new("crates/bar/Cargo.toml")),
310 Some("bar".to_string())
311 );
312
313 assert_eq!(analyzer.file_to_package(Path::new("Cargo.toml")), None);
315 assert_eq!(analyzer.file_to_package(Path::new("README.md")), None);
316 }
317
318 #[test]
319 fn test_analyze_commits_per_package() {
320 let temp = TempDir::new().unwrap();
321 let root = create_test_workspace(&temp);
322 init_git_repo(&root);
323
324 let _hash1 = create_commit(&root, "feat: initial commit");
326
327 fs::write(root.join("crates/foo/src/lib.rs"), "// foo updated").unwrap();
329 let hash2 = create_commit(&root, "fix: update foo");
330
331 fs::write(root.join("crates/bar/src/lib.rs"), "// bar updated").unwrap();
333 let hash3 = create_commit(&root, "feat: update bar");
334
335 let package_paths = HashMap::from([
336 ("foo".to_string(), PathBuf::from("crates/foo")),
337 ("bar".to_string(), PathBuf::from("crates/bar")),
338 ]);
339
340 let commits = vec![
341 ConventionalCommit {
342 commit_type: "fix".to_string(),
343 scope: None,
344 breaking: false,
345 description: "update foo".to_string(),
346 body: None,
347 hash: hash2,
348 },
349 ConventionalCommit {
350 commit_type: "feat".to_string(),
351 scope: None,
352 breaking: false,
353 description: "update bar".to_string(),
354 body: None,
355 hash: hash3,
356 },
357 ];
358
359 let analyzer = CommitAnalyzer::new(&root, package_paths);
360 let bumps = analyzer.calculate_bumps(&commits).unwrap();
361
362 assert_eq!(bumps.get("foo"), Some(&BumpType::Patch));
364 assert_eq!(bumps.get("bar"), Some(&BumpType::Minor));
366 }
367
368 #[test]
369 fn test_analyze_commit_affecting_multiple_packages() {
370 let temp = TempDir::new().unwrap();
371 let root = create_test_workspace(&temp);
372 init_git_repo(&root);
373
374 create_commit(&root, "chore: initial");
376
377 fs::write(root.join("crates/foo/src/lib.rs"), "// foo v2").unwrap();
379 fs::write(root.join("crates/bar/src/lib.rs"), "// bar v2").unwrap();
380 let hash = create_commit(&root, "feat: update both");
381
382 let package_paths = HashMap::from([
383 ("foo".to_string(), PathBuf::from("crates/foo")),
384 ("bar".to_string(), PathBuf::from("crates/bar")),
385 ]);
386
387 let commits = vec![ConventionalCommit {
388 commit_type: "feat".to_string(),
389 scope: None,
390 breaking: false,
391 description: "update both".to_string(),
392 body: None,
393 hash,
394 }];
395
396 let analyzer = CommitAnalyzer::new(&root, package_paths);
397 let result = analyzer.analyze(&commits).unwrap();
398
399 assert!(result.contains_key("foo"));
401 assert!(result.contains_key("bar"));
402 }
403
404 #[test]
405 fn test_root_files_not_mapped() {
406 let temp = TempDir::new().unwrap();
407 let root = create_test_workspace(&temp);
408 init_git_repo(&root);
409
410 create_commit(&root, "chore: initial");
412
413 fs::write(root.join("README.md"), "# Updated").unwrap();
415 let hash = create_commit(&root, "docs: update readme");
416
417 let package_paths = HashMap::from([
418 ("foo".to_string(), PathBuf::from("crates/foo")),
419 ("bar".to_string(), PathBuf::from("crates/bar")),
420 ]);
421
422 let commits = vec![ConventionalCommit {
423 commit_type: "docs".to_string(),
424 scope: None,
425 breaking: false,
426 description: "update readme".to_string(),
427 body: None,
428 hash,
429 }];
430
431 let analyzer = CommitAnalyzer::new(&root, package_paths);
432 let result = analyzer.analyze(&commits).unwrap();
433
434 assert!(result.is_empty());
436 }
437}