tldr_cli/commands/bugbot/
changes.rs1use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use anyhow::{Context, Result};
10
11use tldr_core::Language;
12
13#[derive(Debug, Clone)]
15pub struct ChangeDetectionResult {
16 pub changed_files: Vec<PathBuf>,
18 pub detection_method: String,
20}
21
22fn git_changed_files(project: &Path, args: &[&str]) -> Result<Vec<PathBuf>> {
26 let output = Command::new("git")
27 .args(args)
28 .current_dir(project)
29 .output()
30 .context("Failed to run git")?;
31
32 if !output.status.success() {
33 let stderr = String::from_utf8_lossy(&output.stderr);
34 anyhow::bail!("git command failed: {}", stderr);
35 }
36
37 let stdout = String::from_utf8_lossy(&output.stdout);
38 Ok(stdout
39 .lines()
40 .filter(|l| !l.is_empty())
41 .map(|l| project.join(l))
42 .collect())
43}
44
45pub fn detect_changes(
61 project: &Path,
62 base_ref: &str,
63 staged: bool,
64 language: &Language,
65) -> Result<ChangeDetectionResult> {
66 let (raw_files, detection_method) = if staged {
67 let files = git_changed_files(project, &["diff", "--name-only", "--staged"])
68 .context("Failed to list staged changes")?;
69 (files, "git:staged".to_string())
70 } else if base_ref == "HEAD" {
71 let mut files = git_changed_files(project, &["diff", "--name-only", "HEAD"])
73 .context("Failed to list uncommitted changes")?;
74 let staged_files = git_changed_files(project, &["diff", "--name-only", "--staged"])
75 .context("Failed to list staged changes")?;
76 let untracked =
77 git_changed_files(project, &["ls-files", "--others", "--exclude-standard"])
78 .context("Failed to list untracked files")?;
79 files.extend(staged_files);
80 files.extend(untracked);
81 files.sort();
82 files.dedup();
83 (files, "git:uncommitted".to_string())
84 } else {
85 let range = format!("{}...HEAD", base_ref);
86 let files = git_changed_files(project, &["diff", "--name-only", &range])
87 .context("Failed to list base-ref changes")?;
88 (files, format!("git:{}...HEAD", base_ref))
89 };
90
91 let valid_extensions = language.extensions();
93 let changed_files: Vec<PathBuf> = raw_files
94 .into_iter()
95 .filter(|f| {
96 f.extension()
97 .and_then(|e| e.to_str())
98 .map(|ext| {
99 let dotted = format!(".{}", ext);
100 valid_extensions.contains(&dotted.as_str())
101 })
102 .unwrap_or(false)
103 })
104 .collect();
105
106 let changed_files = tldr_core::callgraph::filter_tldrignored(project, changed_files);
108
109 Ok(ChangeDetectionResult {
110 changed_files,
111 detection_method,
112 })
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use tempfile::TempDir;
119
120 fn init_git_repo() -> TempDir {
122 let tmp = TempDir::new().expect("create temp dir");
123 let dir = tmp.path();
124
125 Command::new("git")
126 .args(["init"])
127 .current_dir(dir)
128 .output()
129 .expect("git init");
130
131 Command::new("git")
132 .args(["config", "user.email", "test@test.com"])
133 .current_dir(dir)
134 .output()
135 .expect("git config email");
136
137 Command::new("git")
138 .args(["config", "user.name", "Test"])
139 .current_dir(dir)
140 .output()
141 .expect("git config name");
142
143 std::fs::write(dir.join("README.md"), "# test\n").expect("write readme");
145 Command::new("git")
146 .args(["add", "."])
147 .current_dir(dir)
148 .output()
149 .expect("git add");
150 Command::new("git")
151 .args(["commit", "-m", "init"])
152 .current_dir(dir)
153 .output()
154 .expect("git commit");
155
156 tmp
157 }
158
159 #[test]
160 fn test_detect_changes_no_changes_returns_empty() {
161 let tmp = init_git_repo();
162 let result =
163 detect_changes(tmp.path(), "HEAD", false, &Language::Rust).expect("detect_changes");
164
165 assert!(
166 result.changed_files.is_empty(),
167 "Expected no changed files in a clean repo, got: {:?}",
168 result.changed_files
169 );
170 assert_eq!(result.detection_method, "git:uncommitted");
171 }
172
173 #[test]
174 fn test_detect_changes_staged_method() {
175 let tmp = init_git_repo();
176 let result =
177 detect_changes(tmp.path(), "HEAD", true, &Language::Rust).expect("detect_changes");
178
179 assert_eq!(result.detection_method, "git:staged");
180 }
181
182 #[test]
183 fn test_detect_changes_base_ref_method() {
184 let tmp = init_git_repo();
185 Command::new("git")
187 .args(["branch", "main"])
188 .current_dir(tmp.path())
189 .output()
190 .expect("git branch main");
191
192 let result =
193 detect_changes(tmp.path(), "main", false, &Language::Python).expect("detect_changes");
194
195 assert_eq!(result.detection_method, "git:main...HEAD");
196 }
197
198 #[test]
199 fn test_detect_changes_filters_by_language() {
200 let tmp = init_git_repo();
201 let dir = tmp.path();
202
203 std::fs::write(dir.join("hello.rs"), "fn main() {}\n").expect("write rs");
205 std::fs::write(dir.join("hello.py"), "print('hi')\n").expect("write py");
206 std::fs::write(dir.join("hello.js"), "console.log('hi')\n").expect("write js");
207
208 Command::new("git")
210 .args(["add", "."])
211 .current_dir(dir)
212 .output()
213 .expect("git add");
214
215 let result =
217 detect_changes(dir, "HEAD", true, &Language::Rust).expect("detect_changes rust");
218
219 for f in &result.changed_files {
221 assert_eq!(
222 f.extension().and_then(|e| e.to_str()),
223 Some("rs"),
224 "Expected only .rs files, got: {}",
225 f.display()
226 );
227 }
228 assert!(
229 !result.changed_files.is_empty(),
230 "Expected at least one .rs file in changed_files"
231 );
232
233 let result =
235 detect_changes(dir, "HEAD", true, &Language::Python).expect("detect_changes python");
236
237 for f in &result.changed_files {
238 assert_eq!(
239 f.extension().and_then(|e| e.to_str()),
240 Some("py"),
241 "Expected only .py files, got: {}",
242 f.display()
243 );
244 }
245 assert!(
246 !result.changed_files.is_empty(),
247 "Expected at least one .py file in changed_files"
248 );
249 }
250
251 #[test]
252 fn test_detect_changes_uncommitted_finds_unstaged() {
253 let tmp = init_git_repo();
254 let dir = tmp.path();
255
256 let rs_file = dir.join("lib.rs");
258 std::fs::write(&rs_file, "pub fn old() {}\n").expect("write rs");
259 Command::new("git")
260 .args(["add", "lib.rs"])
261 .current_dir(dir)
262 .output()
263 .expect("git add");
264 Command::new("git")
265 .args(["commit", "-m", "add lib"])
266 .current_dir(dir)
267 .output()
268 .expect("git commit");
269
270 std::fs::write(&rs_file, "pub fn new_version() {}\n").expect("overwrite rs");
272
273 let result =
274 detect_changes(dir, "HEAD", false, &Language::Rust).expect("detect_changes");
275
276 assert_eq!(result.detection_method, "git:uncommitted");
277 assert!(
278 result.changed_files.iter().any(|f| {
279 f.file_name()
280 .and_then(|n| n.to_str())
281 .map(|n| n == "lib.rs")
282 .unwrap_or(false)
283 }),
284 "Expected lib.rs in changed files, got: {:?}",
285 result.changed_files
286 );
287 }
288
289 #[test]
290 fn test_detect_changes_ignores_non_matching_extensions() {
291 let tmp = init_git_repo();
292 let dir = tmp.path();
293
294 std::fs::write(dir.join("app.py"), "x = 1\n").expect("write py");
296 std::fs::write(dir.join("app.js"), "var x = 1;\n").expect("write js");
297 Command::new("git")
298 .args(["add", "."])
299 .current_dir(dir)
300 .output()
301 .expect("git add");
302
303 let result =
304 detect_changes(dir, "HEAD", true, &Language::Rust).expect("detect_changes");
305
306 assert!(
307 result.changed_files.is_empty(),
308 "Expected no Rust files when only .py and .js were changed, got: {:?}",
309 result.changed_files
310 );
311 }
312
313 #[test]
314 fn test_change_detection_result_fields() {
315 let result = ChangeDetectionResult {
316 changed_files: vec![PathBuf::from("src/main.rs")],
317 detection_method: "git:staged".to_string(),
318 };
319 assert_eq!(result.changed_files.len(), 1);
320 assert_eq!(result.detection_method, "git:staged");
321 }
322
323 #[test]
324 fn test_detect_changes_respects_tldrignore() {
325 let tmp = init_git_repo();
326 let dir = tmp.path();
327
328 std::fs::create_dir_all(dir.join("corpus")).unwrap();
330 std::fs::create_dir_all(dir.join("src")).unwrap();
331 std::fs::write(dir.join("corpus/vendored.py"), "x = 1\n").unwrap();
332 std::fs::write(dir.join("src/main.py"), "y = 2\n").unwrap();
333
334 std::fs::write(dir.join(".tldrignore"), "corpus/\n").unwrap();
336
337 Command::new("git")
339 .args(["add", "."])
340 .current_dir(dir)
341 .output()
342 .expect("git add");
343
344 let result =
345 detect_changes(dir, "HEAD", true, &Language::Python).expect("detect_changes");
346
347 assert!(
349 !result.changed_files.iter().any(|f| {
350 f.to_string_lossy().contains("corpus")
351 }),
352 "corpus/ files should be excluded by .tldrignore, got: {:?}",
353 result.changed_files
354 );
355 assert!(
356 result.changed_files.iter().any(|f| {
357 f.file_name()
358 .and_then(|n| n.to_str())
359 .map(|n| n == "main.py")
360 .unwrap_or(false)
361 }),
362 "src/main.py should be present, got: {:?}",
363 result.changed_files
364 );
365 }
366}