1use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5
6use git2::{DiffOptions, Repository};
7
8use crate::error::{Error, Result};
9use crate::graph::DependencyGraph;
10use crate::path_utils;
11
12pub struct ChangeDetector;
14
15impl ChangeDetector {
16 pub fn detect_affected_packages(
18 graph: &DependencyGraph,
19 changed_files: &[impl AsRef<Path>],
20 packages_dir: impl AsRef<Path>,
21 ) -> Result<HashSet<String>> {
22 let packages_dir = packages_dir.as_ref();
23 let mut changed_packages = HashSet::new();
24
25 for file_path in changed_files {
26 let path = file_path.as_ref();
27 if let Some(package_name) = Self::file_to_package(path, packages_dir) {
28 changed_packages.insert(package_name);
29 }
30 }
31
32 graph.affected_packages(&changed_packages.into_iter().collect::<Vec<_>>())
33 }
34
35 pub fn detect_from_git(
36 graph: &DependencyGraph,
37 packages_dir: impl AsRef<Path>,
38 base: Option<&str>,
39 ) -> Result<HashSet<String>> {
40 let base = base.unwrap_or("HEAD");
41 Self::validate_git_ref(base)?;
42 let changed_files = Self::git_diff_with_libgit2(base)?;
43 Self::detect_affected_packages(graph, &changed_files, packages_dir)
44 }
45
46 pub fn detect_from_stdin(
48 graph: &DependencyGraph,
49 packages_dir: impl AsRef<Path>,
50 ) -> Result<HashSet<String>> {
51 use std::io::{self, BufRead};
52
53 let stdin = io::stdin();
54 let mut changed_files = Vec::new();
55
56 for line in stdin.lock().lines() {
57 let line = line.map_err(Error::Io)?;
58 let path = PathBuf::from(line.trim());
59 if !path.as_os_str().is_empty() {
60 changed_files.push(path);
61 }
62 }
63
64 Self::detect_affected_packages(graph, &changed_files, packages_dir)
65 }
66
67 fn file_to_package(file_path: &Path, packages_dir: &Path) -> Option<String> {
68 path_utils::file_to_package(file_path, packages_dir)
69 }
70
71 fn validate_git_ref(git_ref: &str) -> Result<()> {
72 if git_ref.is_empty() {
73 return Err(Error::Adapter {
74 package: "change-detection".to_string(),
75 message: "Git reference cannot be empty".to_string(),
76 });
77 }
78
79 if git_ref.len() > 256 {
80 return Err(Error::Adapter {
81 package: "change-detection".to_string(),
82 message: "Git reference exceeds maximum length".to_string(),
83 });
84 }
85
86 if git_ref.contains('\0') || git_ref.contains('\n') || git_ref.contains('\r') {
87 return Err(Error::Adapter {
88 package: "change-detection".to_string(),
89 message: "Git reference contains invalid characters".to_string(),
90 });
91 }
92
93 if git_ref.starts_with('-') {
94 return Err(Error::Adapter {
95 package: "change-detection".to_string(),
96 message: "Git reference cannot start with '-'".to_string(),
97 });
98 }
99
100 Ok(())
101 }
102
103 fn git_diff_with_libgit2(base: &str) -> Result<Vec<PathBuf>> {
104 let repo = Repository::open_from_env().map_err(|e| Error::Adapter {
105 package: "change-detection".to_string(),
106 message: format!("Failed to open git repository: {}", e),
107 })?;
108
109 let base_obj = repo.revparse_single(base).map_err(|e| Error::Adapter {
110 package: "change-detection".to_string(),
111 message: format!("Failed to parse git reference '{}': {}", base, e),
112 })?;
113
114 let base_tree = base_obj.peel_to_tree().map_err(|e| Error::Adapter {
115 package: "change-detection".to_string(),
116 message: format!("Failed to get tree from git reference: {}", e),
117 })?;
118
119 let mut diff_opts = DiffOptions::new();
120 diff_opts.include_untracked(false);
121 diff_opts.recurse_untracked_dirs(false);
122
123 let head = repo.head().map_err(|e| Error::Adapter {
124 package: "change-detection".to_string(),
125 message: format!("Failed to get HEAD: {}", e),
126 })?;
127
128 let head_tree = head.peel_to_tree().map_err(|e| Error::Adapter {
129 package: "change-detection".to_string(),
130 message: format!("Failed to get tree from HEAD: {}", e),
131 })?;
132
133 let diff = repo
134 .diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut diff_opts))
135 .map_err(|e| Error::Adapter {
136 package: "change-detection".to_string(),
137 message: format!("Failed to compute git diff: {}", e),
138 })?;
139
140 let mut changed_files = Vec::new();
141 diff.foreach(
142 &mut |delta, _| {
143 if let Some(path) = delta.new_file().path() {
144 changed_files.push(path.to_path_buf());
145 }
146 true
147 },
148 None,
149 None,
150 None,
151 )
152 .map_err(|e| Error::Adapter {
153 package: "change-detection".to_string(),
154 message: format!("Failed to iterate over git diff: {}", e),
155 })?;
156
157 Ok(changed_files)
158 }
159}